@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
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Keep RLM `context` in sync with the disk after native edit/write.
3
+ *
4
+ * Seed packs file bodies once; without this, search/map_files/llm still see pre-edit text.
5
+ */
6
+
7
+ import { readFile } from "node:fs/promises";
8
+ import { isAbsolute, relative, resolve } from "node:path";
9
+ import { estimateTokens } from "../text/tokens.ts";
10
+ import type { ContextFile } from "./types.ts";
11
+
12
+ /** Paths that look like tool file targets. */
13
+ export function extractEditPaths(input: unknown): readonly string[] {
14
+ if (typeof input !== "object" || input === null) return Object.freeze([]);
15
+ const o = input as Record<string, unknown>;
16
+ const keys = ["path", "file_path", "filePath", "filename", "file"] as const;
17
+ const out: string[] = [];
18
+ for (const k of keys) {
19
+ const v = o[k];
20
+ if (typeof v === "string" && v.trim() !== "") out.push(v.trim());
21
+ }
22
+ // Some tools pass { path, oldText, newText } only — already covered.
23
+ return Object.freeze(out);
24
+ }
25
+
26
+ /**
27
+ * Normalize disk path to how cwd-seed entries usually appear (relative to cwd when under cwd).
28
+ */
29
+ export function normalizeContextPath(filePath: string, cwd: string): string {
30
+ const abs = isAbsolute(filePath) ? resolve(filePath) : resolve(cwd, filePath);
31
+ const rel = relative(cwd, abs);
32
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return abs;
33
+ return rel.split("\\").join("/");
34
+ }
35
+
36
+ function pathMatches(entryPath: string, target: string, cwd: string): boolean {
37
+ if (entryPath === target) return true;
38
+ const a = normalizeContextPath(entryPath, cwd);
39
+ const b = normalizeContextPath(target, cwd);
40
+ if (a === b) return true;
41
+ // suffix match for namespaced entries
42
+ return entryPath.endsWith("/" + target) || entryPath.endsWith(target);
43
+ }
44
+
45
+ /**
46
+ * Upsert one file into a context payload list. Returns a **new** array (identity change
47
+ * so BM25 stamp invalidates when the worker rebinds `context`).
48
+ */
49
+ export function upsertContextFile(
50
+ payload: unknown,
51
+ filePath: string,
52
+ content: string,
53
+ cwd: string,
54
+ ): ContextFile[] {
55
+ const path = normalizeContextPath(filePath, cwd);
56
+ const tokens = estimateTokens(content.length);
57
+ const entry: ContextFile = Object.freeze({ path, content, tokens });
58
+
59
+ if (!Array.isArray(payload)) {
60
+ return [entry];
61
+ }
62
+
63
+ const next = new Array<ContextFile>(payload.length + 1);
64
+ let n = 0;
65
+ let replaced = false;
66
+ for (let i = 0; i < payload.length; i++) {
67
+ const item: unknown = payload[i];
68
+ if (
69
+ item !== null &&
70
+ typeof item === "object" &&
71
+ "path" in item &&
72
+ typeof (item as { path: unknown }).path === "string" &&
73
+ pathMatches((item as { path: string }).path, path, cwd)
74
+ ) {
75
+ next[n++] = entry;
76
+ replaced = true;
77
+ } else if (
78
+ item !== null &&
79
+ typeof item === "object" &&
80
+ "path" in item &&
81
+ "content" in item &&
82
+ typeof (item as { path: unknown }).path === "string" &&
83
+ typeof (item as { content: unknown }).content === "string"
84
+ ) {
85
+ const e = item as { path: string; content: string; tokens?: number };
86
+ next[n++] = Object.freeze({
87
+ path: e.path,
88
+ content: e.content,
89
+ tokens: typeof e.tokens === "number" ? e.tokens : estimateTokens(e.content.length),
90
+ });
91
+ }
92
+ // drop non-file entries silently (shouldn't appear in file bundles)
93
+ }
94
+ if (!replaced) next[n++] = entry;
95
+ next.length = n;
96
+ return next;
97
+ }
98
+
99
+ /** Read file from disk; return null if missing/unreadable. */
100
+ export async function readDiskFile(filePath: string, cwd: string): Promise<string | null> {
101
+ const abs = isAbsolute(filePath) ? resolve(filePath) : resolve(cwd, filePath);
102
+ try {
103
+ return await readFile(abs, "utf8");
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Python snippet that rebinds `context` with updated path content and forces a new list id
111
+ * so BM25 rebuilds on next search.
112
+ */
113
+ export function patchContextExecCode(filePath: string, content: string, cwd: string): string {
114
+ const path = normalizeContextPath(filePath, cwd);
115
+ // JSON for safe embedding in Python string literals
116
+ const pathLit = JSON.stringify(path);
117
+ const contentLit = JSON.stringify(content);
118
+ const tokens = estimateTokens(content.length);
119
+ return `
120
+ _path = ${pathLit}
121
+ _content = ${contentLit}
122
+ _tokens = ${tokens}
123
+ _old = context if isinstance(context, list) else []
124
+ _next = []
125
+ _found = False
126
+ for _e in _old:
127
+ if isinstance(_e, dict) and str(_e.get("path", "")) in (_path, _path.replace("\\\\", "/")):
128
+ _next.append({"path": _path, "content": _content, "tokens": _tokens})
129
+ _found = True
130
+ elif isinstance(_e, dict) and (
131
+ str(_e.get("path", "")).endswith("/" + _path) or str(_e.get("path", "")).endswith(_path)
132
+ ):
133
+ _next.append({"path": str(_e.get("path")), "content": _content, "tokens": _tokens})
134
+ _found = True
135
+ else:
136
+ _next.append(_e)
137
+ if not _found:
138
+ _next.append({"path": _path, "content": _content, "tokens": _tokens})
139
+ context = _next
140
+ `.trim();
141
+ }
@@ -13,10 +13,10 @@ import { buildAddContextHandler } from "../bridge/add-context.ts";
13
13
  import { mergeIntoContext } from "../context/merge.ts";
14
14
  import {
15
15
  createSubcallHandlers,
16
+ createTaskRegistry,
16
17
  type Invocation,
17
- } from "../bridge/subcall-handlers.ts";
18
+ } from "../bridge/handlers/index.ts";
18
19
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
19
- import { resolveModelId } from "../config/settings.ts";
20
20
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
21
21
  import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
22
22
  import type { RlmEmitter } from "../tool/rlm-events.ts";
@@ -30,7 +30,6 @@ import { appendUserMessage } from "./history.ts";
30
30
  import { runTurn } from "./iteration.ts";
31
31
  import { type Limits, LimitError, LimitGuard } from "./limits.ts";
32
32
  import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
33
- import { formatError } from "../util/errors.ts";
34
33
  import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
35
34
 
36
35
  /**
@@ -71,20 +70,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
71
70
  emitter.emitTurn(0, deps.config.maxIterations);
72
71
  }
73
72
 
74
- const overrideModel = input.modelOverride ? resolveModelId(deps.registry, input.modelOverride) : undefined;
75
- if (input.modelOverride && !overrideModel) {
76
- if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, status: "error", detail: "unknown model override" });
77
- else emitter.emitStatus("error");
78
- return {
79
- answer: formatError(`unknown model override '${input.modelOverride}'`),
80
- iterations: 0,
81
- costUsd: 0,
82
- inputTokens: 0,
83
- outputTokens: 0,
84
- durationMs: 0,
85
- };
86
- }
87
- const model = overrideModel ?? deps.model;
73
+ const model = deps.model;
88
74
 
89
75
  // Create LimitGuard BEFORE the bridge so sub-LLM usage feeds into it.
90
76
  // Children inherit the parent's remaining timeout (propagated as remaining amount, not
@@ -117,6 +103,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
117
103
  // run can settle or abort it first (a child engine left running would keep spending).
118
104
  let detachedInFlight = 0;
119
105
  let detachedIdle: (() => void) | undefined;
106
+ // One registry per run — unawaited task reminders share the same map as await handlers.
107
+ const taskRegistry = createTaskRegistry();
120
108
  const subcalls = createSubcallHandlers({
121
109
  resolve: () => invocation,
122
110
  gates: deps.gates
@@ -140,7 +128,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
140
128
  if (detachedInFlight === 0) detachedIdle?.();
141
129
  }
142
130
  },
143
- });
131
+ }, taskRegistry);
144
132
  /** Wait (bounded) for detached work before the sandbox goes away. */
145
133
  const settleDetached = async (): Promise<void> => {
146
134
  if (detachedInFlight === 0) return;
@@ -189,6 +177,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
189
177
  maxPromptChars: deps.config.maxPromptChars,
190
178
  contextLoader: deps.config.contextLoader,
191
179
  child: input.depth > 0,
180
+ depth: input.depth,
192
181
  });
193
182
 
194
183
  const contextHandlers = deps.config.contextLoader
@@ -251,6 +240,15 @@ export function createEngine(deps: EngineDeps): RunRlm {
251
240
  pendingReplOutputs = undefined;
252
241
  }
253
242
 
243
+ // Soft runtime nudge (rlm_test parity): remind the model to await pending host tasks.
244
+ const pendingIds = taskRegistry.awaitDeps.unawaitedIds();
245
+ if (pendingIds.length > 0) {
246
+ appendUserMessage(
247
+ history,
248
+ `[runtime] Unawaited task_ids: ${pendingIds.join(", ")} — call await before finish.`,
249
+ );
250
+ }
251
+
254
252
  appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations));
255
253
 
256
254
  // rootSampling fields win; smartReasoning is the default reasoning when not overridden.
package/src/core/types.ts CHANGED
@@ -55,7 +55,7 @@ export interface RlmConfig {
55
55
  * Keeps each turn short so the next turn's input stays manageable.
56
56
  * `reasoning` is read from `smartReasoning` if omitted here. */
57
57
  readonly rootSampling?: Readonly<Sampling>;
58
- /** System prompt injected into every llm_query / llm_query_batched sub-call.
58
+ /** System prompt injected into every llm_query / llm_batch sub-call.
59
59
  * Instructs the worker model to respond concisely.
60
60
  * undefined = no system prompt (raw completion). */
61
61
  readonly subSystemPrompt?: string;
@@ -73,8 +73,6 @@ export interface RlmInput {
73
73
  readonly depth: number;
74
74
  /** AgentTree node to attach this run's node under (set when recursing). */
75
75
  readonly parentNodeId?: string;
76
- /** "provider/id" — overrides the root model for this run (set by recursive rlm_query). */
77
- readonly modelOverride?: string;
78
76
  /** Remaining timeout for this subtree (set by parent from its LimitGuard). */
79
77
  readonly remainingTimeoutMs?: number;
80
78
  }
package/src/index.ts CHANGED
@@ -18,17 +18,48 @@ import { BackgroundTasks } from "./tool/background-tasks.ts";
18
18
  import { resolve } from "node:path";
19
19
  import { resolveSource } from "./context/resolve.ts";
20
20
  import { formatContextListing } from "./context/listing.ts";
21
+ import { extractEditPaths, readDiskFile } from "./context/refresh.ts";
21
22
  import type { AddContextHandlerBundle } from "./bridge/add-context.ts";
22
- import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/native.ts";
23
- import { bashCommandFromInput, isFileReadingCommand, capToolResultText, BASH_BLOCK_REASON } from "./mode/native-guards.ts";
23
+ import { buildNativeSystemPrompt } from "./prompts/native.ts";
24
+ import { capToolResultText } from "./mode/native-guards.ts";
25
+ import {
26
+ isSubagentChildBypass,
27
+ commitSubagentForceActivation,
28
+ shouldEnforceNativeReaderBlock,
29
+ processRlmDepth,
30
+ } from "./mode/subagent.ts";
24
31
  import { errorMessage } from "./util/errors.ts";
32
+ import { trace, traceEnabled } from "./util/trace.ts";
33
+
34
+ export {
35
+ isSubagentChildBypass,
36
+ commitSubagentForceActivation,
37
+ shouldEnforceNativeReaderBlock,
38
+ processRlmDepth,
39
+ } from "./mode/subagent.ts";
25
40
 
26
- const BLOCKED_NATIVE_TOOLS = Object.freeze(new Set(["read", "grep"]));
27
41
  /** How often to keep the parent sandbox's request watchdog alive during detached work. */
28
42
  const WATCHDOG_HEARTBEAT_MS = 30_000;
29
- const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls"]));
43
+ /** Soft token guard — cap bulk tool stdout; do NOT hard-block read/grep/bash readers. */
44
+ const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls", "read", "grep"]));
30
45
 
31
46
  export default function rlmExtension(pi: ExtensionAPI): void {
47
+ // Subagent children may bypass full RLM registration. Env fast path when
48
+ // PI_SUBAGENT_CHILD=1 (unless force-in under the depth cap). See mode/subagent.ts.
49
+ if (isSubagentChildBypass()) {
50
+ if (traceEnabled) {
51
+ trace("subagent.bypass", {
52
+ reason: process.env.PI_RLM_FORCE_IN_SUBAGENT === "1" ? "force_depth_cap" : "child",
53
+ depth: processRlmDepth(),
54
+ });
55
+ }
56
+ return;
57
+ }
58
+ commitSubagentForceActivation();
59
+ if (traceEnabled && process.env.PI_SUBAGENT_CHILD === "1") {
60
+ trace("subagent.force", { depth: processRlmDepth() });
61
+ }
62
+
32
63
  // Init synchronously with defaults — ensures commands/tools/handlers register before session_start
33
64
  const config = mergeConfig({});
34
65
  const controller = new RlmController(config);
@@ -151,7 +182,23 @@ export default function rlmExtension(pi: ExtensionAPI): void {
151
182
 
152
183
  if (controller.savedLlmRef) {
153
184
  const resolved = resolveModelId(ctx.modelRegistry, controller.savedLlmRef);
154
- if (resolved) controller.llmModel = resolved;
185
+ if (resolved) {
186
+ controller.llmModel = resolved;
187
+ } else {
188
+ // Keep the pin on disk/controller — do not fall back permanently. Runtime uses
189
+ // cheapest until the catalog has the model again; surface that once per session.
190
+ console.warn(
191
+ `[rlm] pinned sub-LLM ${controller.savedLlmRef} not in registry; using cheapest until it reappears`,
192
+ );
193
+ try {
194
+ ctx.ui.notify(
195
+ `RLM: pinned llm=${controller.savedLlmRef} unavailable — using cheapest until it is`,
196
+ "warning",
197
+ );
198
+ } catch {
199
+ // Some hosts have no UI at session_start.
200
+ }
201
+ }
155
202
  }
156
203
 
157
204
  // Re-register repl tool each session to pick up model provider changes
@@ -203,9 +250,16 @@ export default function rlmExtension(pi: ExtensionAPI): void {
203
250
  setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
204
251
  });
205
252
 
206
- // ── System prompt: native RLM mode addendum (only when enabled) ──
253
+ /** True when the native-mode trade holds: enabled AND repl is in the active tool set. */
254
+ const nativeTradeHolds = (): boolean =>
255
+ shouldEnforceNativeReaderBlock({
256
+ enabled: controller.enabled,
257
+ activeToolNames: typeof pi.getActiveTools === "function" ? pi.getActiveTools() : undefined,
258
+ });
259
+
260
+ // ── System prompt: native RLM mode addendum (only when the trade holds) ──
207
261
  pi.on("before_agent_start", async (event) => {
208
- if (!controller.enabled) return;
262
+ if (!nativeTradeHolds()) return;
209
263
  return { systemPrompt: event.systemPrompt + "\n\n" + buildNativeSystemPrompt() };
210
264
  });
211
265
 
@@ -216,9 +270,9 @@ export default function rlmExtension(pi: ExtensionAPI): void {
216
270
  const filtered = event.messages.filter(
217
271
  (message) =>
218
272
  !(message.role === "custom" && message.customType === "rlm-intro")
219
- && !(message.role === "user" && typeof message.content === "string" && message.content === NATIVE_TURN_REMINDER),
273
+
220
274
  );
221
- if (!controller.enabled) return { messages: filtered };
275
+ if (!nativeTradeHolds()) return { messages: filtered };
222
276
 
223
277
  type PiMessage = (typeof filtered)[number];
224
278
 
@@ -228,11 +282,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
228
282
  listingPayloadRef = payload;
229
283
  const listing = formatContextListing(payload);
230
284
  const instruction = [
231
- "ANALYZE with repl({code}) read/grep are DISABLED.",
232
- "Files you have loaded live in the Python REPL `context` variable (starts empty; cwd seeds on first repl()).",
233
- "Locate with search()/grep_context()/outline() (free), then delegate bulk reading to",
234
- "map_files()/llm_query_batched(). Use add_context(path) for external dirs/files/docs/git URLs.",
235
- "If credits exhausted → report and stop.",
285
+ "Prefer repl({code}) for bulk analysis: free search/grep/outline, then fan-out Tasks.",
286
+ "Multi-module work rlm_batch (or rlm_query); one-shot extracts map_files/llm_batch.",
287
+ "Always-spawn returns Task (↯bg); only await_task has content — fire-all then await.",
288
+ "Large tool/repl outputs are capped. Files live in REPL `context` (cwd seeds first repl()).",
289
+ "add_context(path) for external dirs/files/docs/git. Credits exhausted → report and stop.",
236
290
  "",
237
291
  ].join("\n");
238
292
  filtered.unshift({
@@ -242,36 +296,39 @@ export default function rlmExtension(pi: ExtensionAPI): void {
242
296
  } as PiMessage);
243
297
  }
244
298
 
245
- // Per-turn last-position reminder (not persisted — context hook rebuilds every request)
246
- filtered.push({
247
- role: "user" as const,
248
- content: NATIVE_TURN_REMINDER,
249
- timestamp: 0,
250
- } as PiMessage);
251
299
 
252
300
  return { messages: filtered };
253
301
  });
254
302
 
255
- // ── Native mode restrictions: keep bulk file content out of root-model context ──
256
- // `edit`/`write` stay unblocked so the agent modifies files through Pi's native
257
- // tool flow (visible to all plugins, +/- diff preview). File reading/searching
258
- // belongs in the REPL, and bash output is capped as a backstop.
259
- pi.on("tool_call", async (event) => {
260
- if (!controller.enabled) return;
261
- if (BLOCKED_NATIVE_TOOLS.has(event.toolName)) {
262
- return {
263
- block: true,
264
- reason: "RLM mode active. Use repl({code}) to read files and search the repository — loaded files live in the REPL `context` variable (cwd seeds on first call). Use `edit`/`write` for file changes. If sub-LLM credits are exhausted, report to the user.",
265
- };
266
- }
267
- const bashCommand = event.toolName === "bash" ? bashCommandFromInput(event.input) : undefined;
268
- if (bashCommand !== undefined && isFileReadingCommand(bashCommand)) {
269
- return { block: true, reason: BASH_BLOCK_REASON };
303
+ // Soft token guard only never hard-block read/grep/bash. Large tool results are capped.
304
+ // After edit/write, re-read disk into RLM context so search/llm see fresh content.
305
+ const MUTATING_FILE_TOOLS = Object.freeze(new Set(["edit", "write"]));
306
+
307
+ pi.on("tool_result", async (event, ctx) => {
308
+ // ── Keep RLM context fresh after native file mutations ──
309
+ if (
310
+ nativeTradeHolds()
311
+ && MUTATING_FILE_TOOLS.has(event.toolName)
312
+ && event.isError !== true
313
+ ) {
314
+ const cwd = resolve(ctx?.cwd ?? process.cwd());
315
+ const paths = extractEditPaths(event.input);
316
+ for (const p of paths) {
317
+ const body = await readDiskFile(p, cwd);
318
+ if (body === null) continue;
319
+ try {
320
+ await sandboxManager.refreshFileFromDisk(p, body, cwd);
321
+ // Listing must re-inject if we rewrote payload identity
322
+ listingPayloadRef = undefined;
323
+ } catch (err) {
324
+ if (traceEnabled) {
325
+ trace("context.refresh_fail", { path: p, error: errorMessage(err) });
326
+ }
327
+ }
328
+ }
270
329
  }
271
- });
272
330
 
273
- pi.on("tool_result", async (event) => {
274
- if (!controller.enabled || !CAPPED_RESULT_TOOLS.has(event.toolName)) return;
331
+ if (!nativeTradeHolds() || !CAPPED_RESULT_TOOLS.has(event.toolName)) return;
275
332
  let changed = false;
276
333
  const content = event.content.map((c) => {
277
334
  if (c.type !== "text") return c;
@@ -43,7 +43,7 @@ export function isFileReadingCommand(command: string): boolean {
43
43
  export const BASH_BLOCK_REASON =
44
44
  "RLM mode: reading files via bash is blocked — that dumps file content into the root model's " +
45
45
  "context. All files are pre-loaded in the REPL `context` variable: use repl({code}) with Python " +
46
- "string/regex search, and delegate bulk analysis to llm_query / llm_query_batched / " +
46
+ "string/regex search, and delegate bulk analysis to llm_query / llm_batch / " +
47
47
  "llm_query_chunked. bash is for RUNNING things (tests, builds, git).";
48
48
 
49
49
  /** Max chars of tool output forwarded to the root model (≈1K tokens). */
@@ -51,12 +51,12 @@ export const TOOL_RESULT_CAP = 4_000;
51
51
 
52
52
  const CAP_NOTE =
53
53
  `\n[RLM: tool output capped at ${TOOL_RESULT_CAP.toLocaleString()} chars to protect the root ` +
54
- "model's context — route bulk text through repl() + llm_query_chunked / llm_query_batched.]";
54
+ "model's context — route bulk text through repl() + llm_query_chunked / llm_batch.]";
55
55
 
56
56
  const REPL_CAP_NOTE =
57
57
  `\n[RLM: repl() stdout capped at ${TOOL_RESULT_CAP.toLocaleString()} chars — printing bulk text ` +
58
58
  "is useless. Keep results in REPL variables and delegate semantic reading to llm_query / " +
59
- "llm_query_batched / llm_query_chunked.]";
59
+ "llm_batch / llm_query_chunked.]";
60
60
 
61
61
  /** Shared truncation core. Returns undefined when under the cap (leave the text untouched). */
62
62
  function capText(text: string, note: string): string | undefined {
@@ -86,7 +86,7 @@ export function replDelegationNudge(stdoutChars: number, delegated: boolean): st
86
86
  if (delegated || stdoutChars <= NUDGE_STDOUT_CHARS) return undefined;
87
87
  return (
88
88
  `\n[RLM: this repl() printed ${stdoutChars.toLocaleString()} chars with 0 sub-LLM calls — ` +
89
- "if you were READING, delegate via llm_query / llm_query_batched / llm_query_chunked. " +
89
+ "if you were READING, delegate via llm_query / llm_batch / llm_query_chunked. " +
90
90
  "Authoring an edit body yourself is correct and needs no delegation.]"
91
91
  );
92
92
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Subagent / process-boundary isolation for RLM.
3
+ *
4
+ * Two layers:
5
+ * 1. Env fast path — packages that set PI_SUBAGENT_CHILD=1 fully bypass RLM.
6
+ * 2. Capability gate — never confiscate native readers unless `repl` is in the
7
+ * active tool set (paper trade: scaffold only if the REPL substitute exists).
8
+ *
9
+ * In-process rlm_query depth is handled by bridge/handlers childRun; this module
10
+ * only covers OS-process children (pi subagents), which restart at depth 0.
11
+ */
12
+ import { DEFAULT_CONFIG } from "../config/defaults.ts";
13
+
14
+ export const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
15
+ export const RLM_FORCE_IN_SUBAGENT_ENV = "PI_RLM_FORCE_IN_SUBAGENT";
16
+ export const RLM_DEPTH_ENV = "PI_RLM_DEPTH";
17
+
18
+ /** Cross-process depth from env. Missing / invalid → 0. */
19
+ export function processRlmDepth(): number {
20
+ const raw = process.env[RLM_DEPTH_ENV];
21
+ if (raw === undefined || raw === "") return 0;
22
+ const n = Number.parseInt(raw, 10);
23
+ if (!Number.isFinite(n) || n < 0) return 0;
24
+ return n;
25
+ }
26
+
27
+ /**
28
+ * True when this process should not activate RLM at all (no tools / hooks / flags).
29
+ *
30
+ * - Parent (no PI_SUBAGENT_CHILD=1) → false.
31
+ * - Child without force → true.
32
+ * - Child with force but depth >= maxDepth → true (refuse force; paper §7 cost bound).
33
+ * - Child with force and depth < maxDepth → false (experimental opt-in).
34
+ */
35
+ export function isSubagentChildBypass(maxDepth: number = DEFAULT_CONFIG.maxDepth): boolean {
36
+ if (process.env[SUBAGENT_CHILD_ENV] !== "1") return false;
37
+ if (process.env[RLM_FORCE_IN_SUBAGENT_ENV] !== "1") return true;
38
+ return processRlmDepth() >= maxDepth;
39
+ }
40
+
41
+ /**
42
+ * Call only when RLM will activate. Scrubs force so grandchildren that inherit env
43
+ * do not re-open unbounded force; bumps PI_RLM_DEPTH for any re-set force path.
44
+ * No-op when not a forced child under the depth cap.
45
+ */
46
+ export function commitSubagentForceActivation(maxDepth: number = DEFAULT_CONFIG.maxDepth): void {
47
+ if (process.env[SUBAGENT_CHILD_ENV] !== "1") return;
48
+ if (process.env[RLM_FORCE_IN_SUBAGENT_ENV] !== "1") return;
49
+ if (processRlmDepth() >= maxDepth) return;
50
+ const next = processRlmDepth() + 1;
51
+ delete process.env[RLM_FORCE_IN_SUBAGENT_ENV];
52
+ process.env[RLM_DEPTH_ENV] = String(next);
53
+ }
54
+
55
+ /**
56
+ * RLM's native-mode trade: confiscate read/grep (and bash readers) only when the
57
+ * substitute is actually callable. Fail-open when the active tool list is unknown
58
+ * or does not include `repl` (official pi subagent uses --tools without repl).
59
+ */
60
+ export function shouldEnforceNativeReaderBlock(opts: {
61
+ readonly enabled: boolean;
62
+ readonly activeToolNames: readonly string[] | undefined;
63
+ }): boolean {
64
+ if (!opts.enabled) return false;
65
+ const names = opts.activeToolNames;
66
+ if (names === undefined) return false;
67
+ return names.includes("repl");
68
+ }