@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
|
@@ -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
|
+
}
|
package/src/core/engine.ts
CHANGED
|
@@ -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/
|
|
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
|
|
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 /
|
|
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
|
|
23
|
-
import {
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
273
|
+
|
|
220
274
|
);
|
|
221
|
-
if (!
|
|
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
|
-
"
|
|
232
|
-
"
|
|
233
|
-
"
|
|
234
|
-
"
|
|
235
|
-
"
|
|
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
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
pi.on("
|
|
260
|
-
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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 /
|
|
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 /
|
|
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
|
-
"
|
|
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 /
|
|
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
|
+
}
|