@hicaru/pi-rlm 0.3.0 → 0.3.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.
- package/README.md +18 -0
- package/package.json +1 -1
- package/src/index.ts +50 -5
- package/src/mode/subagent.ts +68 -0
- 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 +7 -0
- package/src/sandbox/py/hostio.py +57 -0
- package/src/sandbox/py/worker.py +3 -4
- package/src/sandbox/sandbox.ts +8 -1
package/README.md
CHANGED
|
@@ -166,6 +166,24 @@ inherited by every child spawned afterwards.
|
|
|
166
166
|
> each holds a full Python process and its own copy of the inherited context. Error and
|
|
167
167
|
> wall-clock caps (above) still bound a runaway tree.
|
|
168
168
|
|
|
169
|
+
## Subagents and environment
|
|
170
|
+
|
|
171
|
+
RLM never confiscates native file tools (`read` / `grep` / bash readers) unless `repl` is in
|
|
172
|
+
the **active** tool set — the paper's trade is all-or-nothing. Process-boundary subagents that
|
|
173
|
+
spawn pi with a `--tools` allowlist without `repl` therefore keep ordinary file access.
|
|
174
|
+
|
|
175
|
+
Optional env conventions (for packages that want an explicit full bypass):
|
|
176
|
+
|
|
177
|
+
| Env | Meaning |
|
|
178
|
+
|---|---|
|
|
179
|
+
| `PI_SUBAGENT_CHILD=1` | Full RLM bypass in this process (no tools / hooks / flags). |
|
|
180
|
+
| `PI_RLM_FORCE_IN_SUBAGENT=1` | Experimental: opt a child back into RLM. **Consumed on activate** (not inherited after). Refused when `PI_RLM_DEPTH >= maxDepth`. |
|
|
181
|
+
| `PI_RLM_DEPTH` | Cross-process depth counter (default `0`). Bumped when force-in activates. |
|
|
182
|
+
|
|
183
|
+
In-process recursion (`rlm_query`) still uses `maxDepth` from `/rlm-config` and is unrelated to
|
|
184
|
+
these env vars. Set `RLM_TRACE_FILE` to a path for JSONL traces of bypass / force / block-skip
|
|
185
|
+
decisions.
|
|
186
|
+
|
|
169
187
|
## Security
|
|
170
188
|
|
|
171
189
|
- **Key isolation**: provider keys live only in TypeScript (`AuthStorage`); the sandbox
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -21,7 +21,21 @@ import { formatContextListing } from "./context/listing.ts";
|
|
|
21
21
|
import type { AddContextHandlerBundle } from "./bridge/add-context.ts";
|
|
22
22
|
import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/native.ts";
|
|
23
23
|
import { bashCommandFromInput, isFileReadingCommand, capToolResultText, BASH_BLOCK_REASON } from "./mode/native-guards.ts";
|
|
24
|
+
import {
|
|
25
|
+
isSubagentChildBypass,
|
|
26
|
+
commitSubagentForceActivation,
|
|
27
|
+
shouldEnforceNativeReaderBlock,
|
|
28
|
+
processRlmDepth,
|
|
29
|
+
} from "./mode/subagent.ts";
|
|
24
30
|
import { errorMessage } from "./util/errors.ts";
|
|
31
|
+
import { trace, traceEnabled } from "./util/trace.ts";
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
isSubagentChildBypass,
|
|
35
|
+
commitSubagentForceActivation,
|
|
36
|
+
shouldEnforceNativeReaderBlock,
|
|
37
|
+
processRlmDepth,
|
|
38
|
+
} from "./mode/subagent.ts";
|
|
25
39
|
|
|
26
40
|
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. */
|
|
@@ -29,6 +43,23 @@ const WATCHDOG_HEARTBEAT_MS = 30_000;
|
|
|
29
43
|
const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls"]));
|
|
30
44
|
|
|
31
45
|
export default function rlmExtension(pi: ExtensionAPI): void {
|
|
46
|
+
// Subagent children run a native tool flow; RLM's contract is the opposite
|
|
47
|
+
// (block read/grep, route through repl). Env fast path: full bypass 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);
|
|
@@ -203,9 +234,16 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
203
234
|
setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
|
|
204
235
|
});
|
|
205
236
|
|
|
206
|
-
|
|
237
|
+
/** True when the native-mode trade holds: enabled AND repl is in the active tool set. */
|
|
238
|
+
const nativeTradeHolds = (): boolean =>
|
|
239
|
+
shouldEnforceNativeReaderBlock({
|
|
240
|
+
enabled: controller.enabled,
|
|
241
|
+
activeToolNames: typeof pi.getActiveTools === "function" ? pi.getActiveTools() : undefined,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ── System prompt: native RLM mode addendum (only when the trade holds) ──
|
|
207
245
|
pi.on("before_agent_start", async (event) => {
|
|
208
|
-
if (!
|
|
246
|
+
if (!nativeTradeHolds()) return;
|
|
209
247
|
return { systemPrompt: event.systemPrompt + "\n\n" + buildNativeSystemPrompt() };
|
|
210
248
|
});
|
|
211
249
|
|
|
@@ -218,7 +256,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
218
256
|
!(message.role === "custom" && message.customType === "rlm-intro")
|
|
219
257
|
&& !(message.role === "user" && typeof message.content === "string" && message.content === NATIVE_TURN_REMINDER),
|
|
220
258
|
);
|
|
221
|
-
if (!
|
|
259
|
+
if (!nativeTradeHolds()) return { messages: filtered };
|
|
222
260
|
|
|
223
261
|
type PiMessage = (typeof filtered)[number];
|
|
224
262
|
|
|
@@ -256,8 +294,15 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
256
294
|
// `edit`/`write` stay unblocked so the agent modifies files through Pi's native
|
|
257
295
|
// tool flow (visible to all plugins, +/- diff preview). File reading/searching
|
|
258
296
|
// belongs in the REPL, and bash output is capped as a backstop.
|
|
297
|
+
// Fail-open when repl is not active (e.g. --tools allowlist without repl): never
|
|
298
|
+
// confiscate readers without a working substitute (RLM paper §2 trade).
|
|
259
299
|
pi.on("tool_call", async (event) => {
|
|
260
|
-
if (!
|
|
300
|
+
if (!nativeTradeHolds()) {
|
|
301
|
+
if (traceEnabled && controller.enabled) {
|
|
302
|
+
trace("native.block_skip", { toolName: event.toolName, reason: "no_active_repl" });
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
261
306
|
if (BLOCKED_NATIVE_TOOLS.has(event.toolName)) {
|
|
262
307
|
return {
|
|
263
308
|
block: true,
|
|
@@ -271,7 +316,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
271
316
|
});
|
|
272
317
|
|
|
273
318
|
pi.on("tool_result", async (event) => {
|
|
274
|
-
if (!
|
|
319
|
+
if (!nativeTradeHolds() || !CAPPED_RESULT_TOOLS.has(event.toolName)) return;
|
|
275
320
|
let changed = false;
|
|
276
321
|
const content = event.content.map((c) => {
|
|
277
322
|
if (c.type !== "text") return c;
|
|
@@ -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 subcall-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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/sandbox/py/guards.py
CHANGED
|
@@ -19,6 +19,13 @@ import sys
|
|
|
19
19
|
from contextlib import contextmanager
|
|
20
20
|
from typing import Any
|
|
21
21
|
|
|
22
|
+
from hostio import pin_stdio_utf8
|
|
23
|
+
|
|
24
|
+
# Pin UTF-8 before anything reads or writes a frame. On Windows these three default to the
|
|
25
|
+
# locale encoding (cp1252), while the JSONL protocol Node speaks is UTF-8 both ways — see
|
|
26
|
+
# issue #7. reconfigure() mutates in place, so the REAL_* captures below are the same objects.
|
|
27
|
+
pin_stdio_utf8()
|
|
28
|
+
|
|
22
29
|
# Capture the REAL stdio before exec() redirects sys.stdout/sys.stderr into buffers.
|
|
23
30
|
# All protocol writes must go to the real stdout even while user code's prints are captured.
|
|
24
31
|
REAL_STDOUT = sys.stdout
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Host↔worker transport, pinned to UTF-8.
|
|
2
|
+
|
|
3
|
+
Every byte the host hands this worker is UTF-8: the context temp files come from Node's
|
|
4
|
+
FileHandle.write(string) (default utf8, see sandbox/context-file.ts) and the JSONL frames come
|
|
5
|
+
off a pipe Node writes the same way. Python does NOT match that — text I/O defaults to the
|
|
6
|
+
locale encoding, which is cp1252 on a Western Windows install. That mismatch is issue #7: a
|
|
7
|
+
packed repo holding any non-ASCII byte raised UnicodeDecodeError before the REPL ever ran.
|
|
8
|
+
|
|
9
|
+
Encoding is therefore never left to the locale here. Interpreter-wide UTF-8 mode (-X utf8=1,
|
|
10
|
+
set by the host in sandbox.ts) covers model-written open(); these two functions cover the
|
|
11
|
+
scaffold's own I/O, and they win outright — PYTHONIOENCODING overrides -X utf8=1, and
|
|
12
|
+
reconfigure() overrides PYTHONIOENCODING.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
ENCODING = "utf-8"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_host_payload(path: str, is_json: bool) -> Any:
|
|
26
|
+
"""Read a payload the host serialized to a temp file.
|
|
27
|
+
|
|
28
|
+
Strict on decode errors by design. The host always writes UTF-8, so a failure here is a
|
|
29
|
+
transport bug, not bad input — and errors="replace" would hand the model silently
|
|
30
|
+
mojibake'd source code instead of surfacing the problem.
|
|
31
|
+
|
|
32
|
+
The explicit encoding= is intentionally redundant with -X utf8=1 under a normal spawn:
|
|
33
|
+
UTF-8 mode already makes the default encoding UTF-8. It still matters so a standalone
|
|
34
|
+
import of this module (or a worker started without -X utf8=1) cannot silently reintroduce
|
|
35
|
+
issue #7. phase-encoding.ts check 6 (AST / EncodingWarning) is the only automated guard
|
|
36
|
+
that encoding= is present — do not drop either side of that net.
|
|
37
|
+
"""
|
|
38
|
+
with io.open(path, "r", encoding=ENCODING) as f:
|
|
39
|
+
return json.load(f) if is_json else f.read()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pin_stdio_utf8() -> None:
|
|
43
|
+
"""Force the three real streams to UTF-8, whatever the locale or PYTHONIOENCODING says.
|
|
44
|
+
|
|
45
|
+
The write side uses surrogatepass, not strict: model code can synthesize a lone surrogate
|
|
46
|
+
(a bad decode, a truncated pair), and json.dumps(ensure_ascii=False) would then raise
|
|
47
|
+
inside _send — killing the worker on a request it had already completed. The read side
|
|
48
|
+
uses replace so a corrupt frame comes back as a "bad json" error response instead of an
|
|
49
|
+
exception that escapes main()'s loop and takes the worker down (fail-soft I/O, AGENTS.md).
|
|
50
|
+
"""
|
|
51
|
+
for stream, errors in (
|
|
52
|
+
(sys.stdin, "replace"),
|
|
53
|
+
(sys.stdout, "surrogatepass"),
|
|
54
|
+
(sys.stderr, "surrogatepass"),
|
|
55
|
+
):
|
|
56
|
+
if isinstance(stream, io.TextIOWrapper): # not a TextIOWrapper under some test harnesses
|
|
57
|
+
stream.reconfigure(encoding=ENCODING, errors=errors)
|
package/src/sandbox/py/worker.py
CHANGED
|
@@ -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,
|
|
@@ -591,8 +592,7 @@ class Worker:
|
|
|
591
592
|
if not isinstance(path, str):
|
|
592
593
|
return "Error: malformed add_context reply (no path)"
|
|
593
594
|
try:
|
|
594
|
-
|
|
595
|
-
payload = json.load(f) if r.get("json") else f.read()
|
|
595
|
+
payload = read_host_payload(path, bool(r.get("json")))
|
|
596
596
|
finally:
|
|
597
597
|
try:
|
|
598
598
|
os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
|
|
@@ -706,8 +706,7 @@ class Worker:
|
|
|
706
706
|
`index` is accepted for protocol compatibility but ignored — there is only
|
|
707
707
|
one context slot. Sources are merged on the host (or via add_context).
|
|
708
708
|
"""
|
|
709
|
-
|
|
710
|
-
payload = json.load(f) if is_json else f.read()
|
|
709
|
+
payload = read_host_payload(path, bool(is_json))
|
|
711
710
|
self._context_payload = payload
|
|
712
711
|
self.ns["context"] = payload
|
|
713
712
|
# Drop legacy multi-slot names if present.
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -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");
|