@hicaru/pi-rlm 0.2.2 → 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 +38 -16
- package/README.ru.md +2 -2
- package/README.zh-CN.md +2 -2
- package/package.json +22 -19
- package/src/bridge/add-context.ts +322 -0
- package/src/bridge/subcall-handlers.ts +1 -1
- package/src/config/defaults.ts +2 -1
- package/src/config/settings.ts +5 -2
- package/src/context/anydoc.ts +67 -0
- package/src/context/listing.ts +70 -0
- package/src/context/md-cache.ts +112 -0
- package/src/context/merge.ts +97 -0
- package/src/context/namespace.ts +180 -0
- package/src/context/resolve.ts +122 -0
- package/src/context/source-dir.ts +166 -0
- package/src/context/source-doc.ts +71 -0
- package/src/context/source-git.ts +51 -0
- package/src/context/source-text.ts +45 -0
- package/src/context/types.ts +88 -0
- package/src/context/walk.ts +250 -0
- package/src/core/engine.ts +15 -19
- package/src/core/types.ts +7 -2
- package/src/index.ts +119 -47
- package/src/mode/rlm-mode.ts +5 -4
- package/src/mode/subagent.ts +68 -0
- package/src/prompts/glossary.ts +31 -28
- package/src/prompts/native.ts +4 -4
- package/src/prompts/system.ts +2 -2
- package/src/sandbox/context-file.ts +4 -4
- package/src/sandbox/interrupts.ts +25 -10
- package/src/sandbox/protocol.ts +13 -7
- 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 +8 -1
- package/src/sandbox/py/hostio.py +57 -0
- package/src/sandbox/py/retrieval.py +1 -1
- package/src/sandbox/py/tasks.py +17 -4
- package/src/sandbox/py/worker.py +71 -52
- package/src/sandbox/sandbox-manager.ts +18 -16
- package/src/sandbox/sandbox.ts +9 -2
- package/src/text/tokens.ts +3 -3
- package/src/tool/repl-details.ts +1 -1
- package/src/tool/repl-tool.ts +31 -19
- package/src/tool/rlm-tool.ts +1 -1
- package/src/ui/config-panel.ts +8 -4
- package/src/bridge/library.ts +0 -190
- package/src/context/library-context.ts +0 -339
- package/src/context/repomix-context.ts +0 -204
package/src/core/engine.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
11
11
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { buildAddContextHandler } from "../bridge/add-context.ts";
|
|
13
|
+
import { mergeIntoContext } from "../context/merge.ts";
|
|
14
14
|
import {
|
|
15
15
|
createSubcallHandlers,
|
|
16
16
|
type Invocation,
|
|
@@ -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 { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
|
|
34
33
|
import { formatError } from "../util/errors.ts";
|
|
35
34
|
import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
|
|
36
35
|
|
|
@@ -128,7 +127,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
128
127
|
getConfig: () => deps.config,
|
|
129
128
|
signal: deps.signal,
|
|
130
129
|
runChild: run,
|
|
131
|
-
// Read lazily: a
|
|
130
|
+
// Read lazily: a source added on turn 3 must reach a child spawned on turn 4. Safe
|
|
132
131
|
// despite being wired before liveContext is assigned — children can only spawn from an
|
|
133
132
|
// interrupt during runTurn, which is strictly after loadContext below.
|
|
134
133
|
getChildContext: () => liveContext,
|
|
@@ -153,19 +152,19 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
153
152
|
};
|
|
154
153
|
let sandbox: PythonSandbox | undefined;
|
|
155
154
|
/**
|
|
156
|
-
* This run's live context:
|
|
157
|
-
* it, so it must grow when
|
|
155
|
+
* This run's live context: whatever was seeded plus every source added so far. Children
|
|
156
|
+
* inherit it, so it must grow when add_context appends (see the handler's onLoaded below).
|
|
158
157
|
*
|
|
159
158
|
* Run-scoped on purpose — recursion means N of these are live at once, and a module-level
|
|
160
159
|
* "current context" would hand a depth-3 child its cousin's world.
|
|
161
160
|
*/
|
|
162
|
-
let liveContext: unknown =
|
|
161
|
+
let liveContext: unknown = [];
|
|
163
162
|
/**
|
|
164
163
|
* This run's hold on the serialized context file. Kept for the whole run so every child that
|
|
165
164
|
* inherits the same payload reuses one file instead of re-serializing the repository.
|
|
166
165
|
*/
|
|
167
166
|
let contextPin: PinnedContext | undefined;
|
|
168
|
-
/** Re-pin after the payload changes identity (
|
|
167
|
+
/** Re-pin after the payload changes identity (mergeIntoContext returns a new array). */
|
|
169
168
|
const repinLiveContext = async (): Promise<void> => {
|
|
170
169
|
const previous = contextPin;
|
|
171
170
|
contextPin = await pinContext(liveContext);
|
|
@@ -188,12 +187,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
188
187
|
orchestrator: deps.config.orchestrator,
|
|
189
188
|
recursion: input.depth + 1 < deps.config.maxDepth,
|
|
190
189
|
maxPromptChars: deps.config.maxPromptChars,
|
|
191
|
-
|
|
190
|
+
contextLoader: deps.config.contextLoader,
|
|
192
191
|
child: input.depth > 0,
|
|
193
192
|
});
|
|
194
193
|
|
|
195
|
-
const
|
|
196
|
-
?
|
|
194
|
+
const contextHandlers = deps.config.contextLoader
|
|
195
|
+
? buildAddContextHandler({
|
|
197
196
|
cwd: runCwd,
|
|
198
197
|
emitter,
|
|
199
198
|
parentId: selfReportId,
|
|
@@ -201,8 +200,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
201
200
|
getContext: () => liveContext,
|
|
202
201
|
onLoaded: async (payload) => {
|
|
203
202
|
// The accumulator is what children inherit, which is what makes inheritance
|
|
204
|
-
// transitive — grandchildren see the
|
|
205
|
-
liveContext =
|
|
203
|
+
// transitive — grandchildren see the source too.
|
|
204
|
+
liveContext = mergeIntoContext(liveContext, payload);
|
|
206
205
|
await repinLiveContext();
|
|
207
206
|
},
|
|
208
207
|
}).handlers
|
|
@@ -217,17 +216,14 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
217
216
|
initTimeoutMs: deps.config.sandboxInitTimeoutMs,
|
|
218
217
|
maxPromptChars: deps.config.maxPromptChars,
|
|
219
218
|
awaitTimeoutS: Math.round(deps.config.requestTimeoutMs / 1000),
|
|
220
|
-
handlers: { ...subcalls, ...
|
|
219
|
+
handlers: { ...subcalls, ...contextHandlers },
|
|
221
220
|
});
|
|
222
221
|
|
|
223
222
|
let history: ChatMsg[] = [{ role: "system", content: system }];
|
|
224
223
|
let pendingReplOutputs: string | undefined;
|
|
225
224
|
|
|
226
|
-
// Context
|
|
227
|
-
liveContext =
|
|
228
|
-
typeof input.context === "object" && input.context !== null && "files" in input.context
|
|
229
|
-
? serializeForSandbox(input.context as ContextBundle)
|
|
230
|
-
: input.context;
|
|
225
|
+
// Context is already a sandbox-ready list (or a raw string for text children).
|
|
226
|
+
liveContext = input.context ?? [];
|
|
231
227
|
contextPin = await pinContext(liveContext);
|
|
232
228
|
await sandbox.loadContextPinned(contextPin);
|
|
233
229
|
for (let i = 0; i < deps.config.maxIterations; i++) {
|
package/src/core/types.ts
CHANGED
|
@@ -42,8 +42,13 @@ export interface RlmConfig {
|
|
|
42
42
|
readonly python: string;
|
|
43
43
|
/** Worker startup wait before treating sandbox init as failed (ms). */
|
|
44
44
|
readonly sandboxInitTimeoutMs: number;
|
|
45
|
-
/** Enable the
|
|
46
|
-
readonly
|
|
45
|
+
/** Enable the add_context() REPL scaffold (external dirs/files/git repos/documents into context). */
|
|
46
|
+
readonly contextLoader: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* When true, the first repl() call seeds `context` with the working directory
|
|
49
|
+
* (un-prefixed paths). When false, context stays empty until add_context is called.
|
|
50
|
+
*/
|
|
51
|
+
readonly autoSeedCwd: boolean;
|
|
47
52
|
/** ThinkingLevel for the root smart model (set via /rlm-config). */
|
|
48
53
|
readonly smartReasoning?: ThinkingLevel;
|
|
49
54
|
/** Output token cap + temperature for the root smart model per turn.
|
package/src/index.ts
CHANGED
|
@@ -15,10 +15,27 @@ import { markdownTheme } from "./ui/theme-adapter.ts";
|
|
|
15
15
|
import { SandboxManager } from "./sandbox/sandbox-manager.ts";
|
|
16
16
|
import { createSubcallGates } from "./util/concurrency.ts";
|
|
17
17
|
import { BackgroundTasks } from "./tool/background-tasks.ts";
|
|
18
|
-
import {
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
import { resolveSource } from "./context/resolve.ts";
|
|
20
|
+
import { formatContextListing } from "./context/listing.ts";
|
|
21
|
+
import type { AddContextHandlerBundle } from "./bridge/add-context.ts";
|
|
19
22
|
import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/native.ts";
|
|
20
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";
|
|
21
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";
|
|
22
39
|
|
|
23
40
|
const BLOCKED_NATIVE_TOOLS = Object.freeze(new Set(["read", "grep"]));
|
|
24
41
|
/** How often to keep the parent sandbox's request watchdog alive during detached work. */
|
|
@@ -26,6 +43,23 @@ const WATCHDOG_HEARTBEAT_MS = 30_000;
|
|
|
26
43
|
const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls"]));
|
|
27
44
|
|
|
28
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
|
+
|
|
29
63
|
// Init synchronously with defaults — ensures commands/tools/handlers register before session_start
|
|
30
64
|
const config = mergeConfig({});
|
|
31
65
|
const controller = new RlmController(config);
|
|
@@ -57,22 +91,37 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
57
91
|
}, WATCHDOG_HEARTBEAT_MS);
|
|
58
92
|
watchdogHeartbeat.unref();
|
|
59
93
|
|
|
60
|
-
|
|
61
|
-
let
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
94
|
+
/** Memoised cwd seed — one resolveSource(pathPrefix:"") per session. */
|
|
95
|
+
let seedPromise: Promise<void> | undefined;
|
|
96
|
+
let cwdSeeded = false;
|
|
97
|
+
/** Live add_context bundle — seed plants the "" sentinel here so add_context(".") is a no-op. */
|
|
98
|
+
let contextBundleRef: AddContextHandlerBundle | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* Payload identity last injected into the context hook. Re-inject only when the payload
|
|
101
|
+
* reference changes (seed, add_context, reset) — not every turn. Keeps the root window small.
|
|
102
|
+
*/
|
|
103
|
+
let listingPayloadRef: unknown = undefined;
|
|
104
|
+
let listingInjected = false;
|
|
105
|
+
const seedContext = async (cwd: string): Promise<void> => {
|
|
106
|
+
if (cwdSeeded) return;
|
|
107
|
+
if (!controller.config.autoSeedCwd) {
|
|
108
|
+
cwdSeeded = true;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
seedPromise ??= resolveSource(cwd, { cwd, pathPrefix: "" })
|
|
65
112
|
.then((result) => {
|
|
113
|
+
// Sticky either way: a failed seed must not re-walk the whole repo on every repl().
|
|
114
|
+
cwdSeeded = true;
|
|
66
115
|
if (!result.ok) {
|
|
67
|
-
console.warn(`[rlm]
|
|
68
|
-
return
|
|
116
|
+
console.warn(`[rlm] context seed failed: ${result.error}`);
|
|
117
|
+
return;
|
|
69
118
|
}
|
|
70
|
-
sandboxManager.contextPayload =
|
|
71
|
-
|
|
72
|
-
|
|
119
|
+
sandboxManager.contextPayload = result.value.payload;
|
|
120
|
+
// Register the cwd seed with the bridge so add_context(".") cannot double the tree.
|
|
121
|
+
contextBundleRef?.markSeededCwd(resolve(cwd));
|
|
73
122
|
})
|
|
74
|
-
.finally(() => {
|
|
75
|
-
|
|
123
|
+
.finally(() => { seedPromise = undefined; });
|
|
124
|
+
await seedPromise;
|
|
76
125
|
};
|
|
77
126
|
|
|
78
127
|
// Load persisted settings async — applied before session_start handler reads controller state
|
|
@@ -152,9 +201,16 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
152
201
|
gates,
|
|
153
202
|
background,
|
|
154
203
|
registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
|
|
204
|
+
registerContextBundle: (bundle) => {
|
|
205
|
+
contextBundleRef = bundle;
|
|
206
|
+
// Tool re-registers each session; re-plant sentinel if seed already landed.
|
|
207
|
+
if (cwdSeeded && Array.isArray(sandboxManager.contextPayload)
|
|
208
|
+
&& sandboxManager.contextPayload.length > 0) {
|
|
209
|
+
bundle.markSeededCwd(resolve(ctx.cwd ?? process.cwd()));
|
|
210
|
+
}
|
|
211
|
+
},
|
|
155
212
|
ensureContext: async () => {
|
|
156
|
-
|
|
157
|
-
if (contextText === undefined) throw new Error("repository context could not be loaded into RLM sandbox");
|
|
213
|
+
await seedContext(ctx.cwd ?? process.cwd());
|
|
158
214
|
},
|
|
159
215
|
}));
|
|
160
216
|
} catch (err) {
|
|
@@ -178,43 +234,50 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
178
234
|
setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
|
|
179
235
|
});
|
|
180
236
|
|
|
181
|
-
|
|
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) ──
|
|
182
245
|
pi.on("before_agent_start", async (event) => {
|
|
183
|
-
if (!
|
|
246
|
+
if (!nativeTradeHolds()) return;
|
|
184
247
|
return { systemPrompt: event.systemPrompt + "\n\n" + buildNativeSystemPrompt() };
|
|
185
248
|
});
|
|
186
249
|
|
|
187
|
-
// ── Context injection:
|
|
188
|
-
|
|
189
|
-
|
|
250
|
+
// ── Context injection: listing of whatever is currently loaded ──
|
|
251
|
+
// Re-inject only when the payload identity changes (seed / add_context), not every turn —
|
|
252
|
+
// the listing can be up to 200 file lines and the plugin exists to shrink the root window.
|
|
253
|
+
pi.on("context", async (event) => {
|
|
190
254
|
const filtered = event.messages.filter(
|
|
191
255
|
(message) =>
|
|
192
256
|
!(message.role === "custom" && message.customType === "rlm-intro")
|
|
193
257
|
&& !(message.role === "user" && typeof message.content === "string" && message.content === NATIVE_TURN_REMINDER),
|
|
194
258
|
);
|
|
195
|
-
if (!
|
|
259
|
+
if (!nativeTradeHolds()) return { messages: filtered };
|
|
196
260
|
|
|
197
261
|
type PiMessage = (typeof filtered)[number];
|
|
198
262
|
|
|
199
|
-
|
|
200
|
-
if (!
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
263
|
+
const payload = sandboxManager.contextPayload;
|
|
264
|
+
if (!listingInjected || payload !== listingPayloadRef) {
|
|
265
|
+
listingInjected = true;
|
|
266
|
+
listingPayloadRef = payload;
|
|
267
|
+
const listing = formatContextListing(payload);
|
|
268
|
+
const instruction = [
|
|
269
|
+
"ANALYZE with repl({code}) — read/grep are DISABLED.",
|
|
270
|
+
"Files you have loaded live in the Python REPL `context` variable (starts empty; cwd seeds on first repl()).",
|
|
271
|
+
"Locate with search()/grep_context()/outline() (free), then delegate bulk reading to",
|
|
272
|
+
"map_files()/llm_query_batched(). Use add_context(path) for external dirs/files/docs/git URLs.",
|
|
273
|
+
"If credits exhausted → report and stop.",
|
|
274
|
+
"",
|
|
275
|
+
].join("\n");
|
|
276
|
+
filtered.unshift({
|
|
277
|
+
role: "user" as const,
|
|
278
|
+
content: instruction + listing,
|
|
279
|
+
timestamp: 0,
|
|
280
|
+
} as PiMessage);
|
|
218
281
|
}
|
|
219
282
|
|
|
220
283
|
// Per-turn last-position reminder (not persisted — context hook rebuilds every request)
|
|
@@ -231,12 +294,19 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
231
294
|
// `edit`/`write` stay unblocked so the agent modifies files through Pi's native
|
|
232
295
|
// tool flow (visible to all plugins, +/- diff preview). File reading/searching
|
|
233
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).
|
|
234
299
|
pi.on("tool_call", async (event) => {
|
|
235
|
-
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
|
+
}
|
|
236
306
|
if (BLOCKED_NATIVE_TOOLS.has(event.toolName)) {
|
|
237
307
|
return {
|
|
238
308
|
block: true,
|
|
239
|
-
reason: "RLM mode active. Use repl({code}) to read files and search the repository —
|
|
309
|
+
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.",
|
|
240
310
|
};
|
|
241
311
|
}
|
|
242
312
|
const bashCommand = event.toolName === "bash" ? bashCommandFromInput(event.input) : undefined;
|
|
@@ -246,7 +316,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
246
316
|
});
|
|
247
317
|
|
|
248
318
|
pi.on("tool_result", async (event) => {
|
|
249
|
-
if (!
|
|
319
|
+
if (!nativeTradeHolds() || !CAPPED_RESULT_TOOLS.has(event.toolName)) return;
|
|
250
320
|
let changed = false;
|
|
251
321
|
const content = event.content.map((c) => {
|
|
252
322
|
if (c.type !== "text") return c;
|
|
@@ -264,9 +334,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
264
334
|
clearInterval(watchdogHeartbeat);
|
|
265
335
|
background.dispose();
|
|
266
336
|
await sandboxManager.dispose();
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
337
|
+
cwdSeeded = false;
|
|
338
|
+
seedPromise = undefined;
|
|
339
|
+
contextBundleRef = undefined;
|
|
340
|
+
listingPayloadRef = undefined;
|
|
341
|
+
listingInjected = false;
|
|
342
|
+
sandboxManager.contextPayload = [];
|
|
271
343
|
});
|
|
272
344
|
}
|
package/src/mode/rlm-mode.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { modelRef, resolveModelId, saveSettings } from "../config/settings.ts";
|
|
|
12
12
|
import { createEngine } from "../core/engine.ts";
|
|
13
13
|
import { limitsFromConfig } from "../core/limits.ts";
|
|
14
14
|
import type { RlmConfig, RlmResult } from "../core/types.ts";
|
|
15
|
-
import {
|
|
15
|
+
import { resolveSource } from "../context/resolve.ts";
|
|
16
16
|
import { RlmEmitter } from "../tool/rlm-events.ts";
|
|
17
17
|
import { formatError } from "../util/errors.ts";
|
|
18
18
|
import { cheapestModel } from "./llm-model.ts";
|
|
@@ -87,13 +87,14 @@ export class RlmController {
|
|
|
87
87
|
this.active = abortController;
|
|
88
88
|
|
|
89
89
|
const done = (async () => {
|
|
90
|
-
// Auto-
|
|
90
|
+
// Auto-seed empty/undefined context from cwd (same resolveSource path as native mode);
|
|
91
|
+
// pass explicit context through.
|
|
91
92
|
let contextValue: unknown = input.context;
|
|
92
93
|
if (contextValue === undefined || (typeof contextValue === "string" && contextValue.trim() === "")) {
|
|
93
94
|
const cwd = ctx.cwd ?? process.cwd();
|
|
94
|
-
const result = await
|
|
95
|
+
const result = await resolveSource(cwd, { cwd, pathPrefix: "", signal: abortController.signal });
|
|
95
96
|
contextValue = result.ok
|
|
96
|
-
?
|
|
97
|
+
? result.value.payload
|
|
97
98
|
: formatError(`failed to pack repository — ${result.error}`);
|
|
98
99
|
}
|
|
99
100
|
const engine = createEngine({
|
|
@@ -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
|
+
}
|
package/src/prompts/glossary.ts
CHANGED
|
@@ -82,13 +82,13 @@ export const SPAWN_GLOSSARY_LINES: readonly string[] = Object.freeze([
|
|
|
82
82
|
*/
|
|
83
83
|
export const RECURSION_CONTEXT_LINES: readonly string[] = Object.freeze([
|
|
84
84
|
"",
|
|
85
|
-
" **What a child sees:** it inherits YOUR `context` —
|
|
86
|
-
"
|
|
87
|
-
" same paths. So send instructions, never file bodies: pasting content you already
|
|
88
|
-
" your tokens twice and buys nothing. Your prompt becomes the child's question.",
|
|
89
|
-
" Narrow its world with `rlm_query(prompt, paths=['src/auth/', '
|
|
85
|
+
" **What a child sees:** it inherits YOUR `context` — every file you have loaded, including",
|
|
86
|
+
" sources under `ctx/<id>/…` — and runs `search` / `grep_context` / `outline` / `map_files`",
|
|
87
|
+
" over the same paths. So send instructions, never file bodies: pasting content you already",
|
|
88
|
+
" share costs your tokens twice and buys nothing. Your prompt becomes the child's question.",
|
|
89
|
+
" Narrow its world with `rlm_query(prompt, paths=['src/auth/', 'ctx/x-9f3a/'])` — path PREFIXES,",
|
|
90
90
|
" not globs. Omit `paths` to hand over everything.",
|
|
91
|
-
" Inheritance is one-way:
|
|
91
|
+
" Inheritance is one-way: sources the child loads, and its whole REPL, die with it — only its",
|
|
92
92
|
" final answer string returns.",
|
|
93
93
|
" At the depth cap `rlm_query` degrades to a plain sub-LLM call with NO context, which is why",
|
|
94
94
|
" this section disappears at the last recursive depth.",
|
|
@@ -99,14 +99,17 @@ export const RECURSION_CONTEXT_LINES: readonly string[] = Object.freeze([
|
|
|
99
99
|
* than a repository the run packed for itself.
|
|
100
100
|
*/
|
|
101
101
|
export const CHILD_CONTEXT_LINES: readonly string[] = Object.freeze([
|
|
102
|
-
" You are a sub-RLM. This `context` is your parent's world —
|
|
103
|
-
"
|
|
104
|
-
" you load die with you, and only your final answer string returns
|
|
102
|
+
" You are a sub-RLM. This `context` is your parent's world — every file it has loaded (cwd",
|
|
103
|
+
" paths un-prefixed; external sources under `ctx/<id>/…`). Answer only the question above;",
|
|
104
|
+
" your REPL and anything you load die with you, and only your final answer string returns.",
|
|
105
105
|
]);
|
|
106
106
|
|
|
107
107
|
/** Why a file the user mentioned may be missing from `context`. */
|
|
108
|
-
export const CONTEXT_EXCLUSION_NOTE =
|
|
109
|
-
" NOTE:
|
|
108
|
+
export const CONTEXT_EXCLUSION_NOTE = [
|
|
109
|
+
" NOTE: `context` holds only the files you have loaded (starts empty; cwd seeds on first use).",
|
|
110
|
+
" Gitignored files and files larger than 1MB of plain text are skipped. Binary documents",
|
|
111
|
+
" (PDF, DOCX, XLSX, PPTX, CSV, …) ARE included — converted to Markdown on the way in.",
|
|
112
|
+
].join("\n");
|
|
110
113
|
|
|
111
114
|
/** The large-on-disk-file protocol (headless + native). */
|
|
112
115
|
export const LARGE_FILE_RULE_LINES: readonly string[] = Object.freeze([
|
|
@@ -200,7 +203,7 @@ export function howToRunCode(): string {
|
|
|
200
203
|
export function replGlossary(
|
|
201
204
|
kind: ContextKind,
|
|
202
205
|
recursion: boolean,
|
|
203
|
-
|
|
206
|
+
contextLoader: boolean,
|
|
204
207
|
child: boolean,
|
|
205
208
|
): string {
|
|
206
209
|
const lines = ["Available in the REPL:"];
|
|
@@ -212,10 +215,10 @@ export function replGlossary(
|
|
|
212
215
|
);
|
|
213
216
|
} else {
|
|
214
217
|
lines.push(
|
|
215
|
-
"- `context`: list[dict] —
|
|
216
|
-
" keys: `path` (
|
|
217
|
-
"
|
|
218
|
-
"
|
|
218
|
+
"- `context`: list[dict] — the files you have loaded (starts empty; cwd seeds on first use).",
|
|
219
|
+
" Each dict has keys: `path` (str), `content` (str), `tokens` (int).",
|
|
220
|
+
" Cwd paths are un-prefixed (real paths for edit/write); external sources land under",
|
|
221
|
+
" `ctx/<source_id>/…`. For large sets, chunk and delegate — never dump raw file bodies.",
|
|
219
222
|
CONTEXT_EXCLUSION_NOTE,
|
|
220
223
|
);
|
|
221
224
|
if (child) lines.push(...CHILD_CONTEXT_LINES);
|
|
@@ -240,22 +243,22 @@ export function replGlossary(
|
|
|
240
243
|
...SPAWN_GLOSSARY_LINES,
|
|
241
244
|
...DELEGATION_GLOSSARY_LINES,
|
|
242
245
|
);
|
|
243
|
-
if (
|
|
246
|
+
if (contextLoader) {
|
|
244
247
|
lines.push(
|
|
245
|
-
"- `
|
|
246
|
-
"
|
|
247
|
-
"
|
|
248
|
-
"
|
|
249
|
-
"
|
|
250
|
-
"
|
|
251
|
-
"
|
|
252
|
-
"
|
|
253
|
-
"
|
|
248
|
+
"- `add_context(source: str) -> dict`: load a dir, file, document, or git URL and **APPEND its",
|
|
249
|
+
" files into `context`** (same shape: path/content/tokens). Documents (PDF, DOCX, XLSX, PPTX,",
|
|
250
|
+
" CSV, …) are converted to Markdown automatically and cached until the source file changes.",
|
|
251
|
+
" Paths are namespaced under `ctx/<source_id>/…` so you can filter by prefix. Returns metadata:",
|
|
252
|
+
" {\"source\", \"source_id\", \"path_prefix\", \"files\", \"chars\", \"context_len\", \"already_loaded\",",
|
|
253
|
+
" \"documents\", \"converted\", \"skipped\"} or an \"Error: ...\" string. `documents` is how many",
|
|
254
|
+
" document-type files landed (incl. cache hits); `converted` is how many were freshly converted",
|
|
255
|
+
" this call. **Never treat the return value as the file list** — always search and chunk the",
|
|
256
|
+
" single variable `context`. Idempotent: re-loading the same source is a no-op.",
|
|
254
257
|
"",
|
|
255
258
|
" ```python",
|
|
256
|
-
|
|
259
|
+
' info = add_context("/path/to/other-project")',
|
|
257
260
|
" # info is metadata; files are already in context under info[\"path_prefix\"]",
|
|
258
|
-
|
|
261
|
+
' lib_files = [f for f in context if f["path"].startswith(info["path_prefix"])]',
|
|
259
262
|
" ```",
|
|
260
263
|
);
|
|
261
264
|
}
|
package/src/prompts/native.ts
CHANGED
|
@@ -17,7 +17,7 @@ function nativeReplGlossary(): string {
|
|
|
17
17
|
"State persists; only `print()` output is returned, so wrap inspections in `print(...)`.",
|
|
18
18
|
"",
|
|
19
19
|
"### REPL Environment",
|
|
20
|
-
"- `context`: list[dict] —
|
|
20
|
+
"- `context`: list[dict] — the files you have loaded (starts empty; cwd seeds on first repl()). Each dict: `path` (str), `content` (str), `tokens` (int).",
|
|
21
21
|
"",
|
|
22
22
|
"Retrieval — free (no sub-LLM call, no tokens). **Start here, before guessing filenames:**",
|
|
23
23
|
"- `search(query, k=10, path_glob=None) -> [{path, line, score, snippet}]` — BM25 over `context`. Returns pointers, not bodies.",
|
|
@@ -30,13 +30,13 @@ function nativeReplGlossary(): string {
|
|
|
30
30
|
"- `llm_query(prompt, model=None) -> str` — one-shot sub-LLM. Use for extraction, summarization, Q&A over a chunk.",
|
|
31
31
|
"- `llm_query_batched(prompts, model=None) -> list[str]` — concurrent sub-LLM calls; output order matches input order.",
|
|
32
32
|
CHUNKED_GLOSSARY_LINE_NATIVE,
|
|
33
|
-
"- `rlm_query(prompt, model=None, paths=None) -> str` — recursive RLM with its own REPL for complex sub-tasks needing iterative reasoning. Prefer llm_query — rlm_query is slower and costlier. The child inherits your `context` (
|
|
33
|
+
"- `rlm_query(prompt, model=None, paths=None) -> str` — recursive RLM with its own REPL for complex sub-tasks needing iterative reasoning. Prefer llm_query — rlm_query is slower and costlier. The child inherits your `context` (loaded files + ctx/ sources) and takes your prompt as its question, so describe the task; never paste file text. `paths=['src/auth/']` narrows its context by prefix.",
|
|
34
34
|
"- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
|
|
35
35
|
"- `spawn(fn, *args) -> Task` / `rlm_await(t)` / `rlm_await_all(ts)` — start `llm_query`, `llm_query_batched`, `llm_query_chunked`, `map_files`, `rlm_query` or `rlm_query_batched` without waiting (NOT `llm_map_reduce`); collect later, order preserved. Tasks outlive the repl() call, so spawn slow work early and await when you need it.",
|
|
36
36
|
"",
|
|
37
37
|
"",
|
|
38
38
|
"- `answers` / `plan` — dicts persisted across every repl() call. Your memo.",
|
|
39
|
-
"- `
|
|
39
|
+
"- `add_context(source) -> dict`: append dir/file/document/git URL into `context` under `ctx/<id>/…`. Documents converted to Markdown. Return is metadata only — always use `context`.",
|
|
40
40
|
"- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
|
|
41
41
|
"- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
|
|
42
42
|
"",
|
|
@@ -90,7 +90,7 @@ export function buildNativeSystemPrompt(): string {
|
|
|
90
90
|
"call (rlm_query for iterative sub-tasks). Deterministic Python over `context` is free and",
|
|
91
91
|
"preferred for lookups. Semantic reading is always delegated.",
|
|
92
92
|
"",
|
|
93
|
-
"
|
|
93
|
+
"Loaded file content lives in the REPL `context` variable (cwd seeds on first call). Use ONLY `repl({code})`.",
|
|
94
94
|
"If sub-LLM credits are exhausted → report the error to the user and stop.",
|
|
95
95
|
"",
|
|
96
96
|
"AUTHORING RULE: sub-LLMs (`llm_query` family) READ — they extract, locate, and summarize.",
|
package/src/prompts/system.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface SystemPromptOptions {
|
|
|
28
28
|
readonly orchestrator?: boolean;
|
|
29
29
|
readonly recursion?: boolean;
|
|
30
30
|
readonly maxPromptChars?: number;
|
|
31
|
-
readonly
|
|
31
|
+
readonly contextLoader?: boolean;
|
|
32
32
|
/** depth > 0 — this run is an rlm_query child and its `context` is the parent's world. */
|
|
33
33
|
readonly child?: boolean;
|
|
34
34
|
}
|
|
@@ -70,7 +70,7 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
|
|
|
70
70
|
howToRunCode(),
|
|
71
71
|
"",
|
|
72
72
|
replGlossary(
|
|
73
|
-
kind, recursion, opts.
|
|
73
|
+
kind, recursion, opts.contextLoader ?? false, opts.child ?? false,
|
|
74
74
|
),
|
|
75
75
|
"",
|
|
76
76
|
"REPL stdout over ~800 characters is truncated to a short excerpt — large results stay in your",
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* Temp-file transport for sandbox context payloads, with refcounted sharing.
|
|
3
3
|
*
|
|
4
4
|
* Two callers, two ownership models, one writer:
|
|
5
|
-
* - `writeContextTempFile` — non-owning. `
|
|
6
|
-
* that file after reading it (see sandbox.ts serviceInterrupt / worker.py `
|
|
5
|
+
* - `writeContextTempFile` — non-owning. `add_context` uses it because the WORKER unlinks
|
|
6
|
+
* that file after reading it (see sandbox.ts serviceInterrupt / worker.py `_add_context`).
|
|
7
7
|
* - `pinContext` — refcounted. Every child RLM of one node inherits the SAME payload, so an
|
|
8
8
|
* 18-way fan-out would otherwise cost 18 serializations and 18 files. Pins are keyed by
|
|
9
|
-
* payload identity, which is a free version key: `
|
|
10
|
-
* NEW array, so
|
|
9
|
+
* payload identity, which is a free version key: `mergeIntoContext` always returns a
|
|
10
|
+
* NEW array, so adding a source mints a new key and old holders keep their own file.
|
|
11
11
|
*
|
|
12
12
|
* Serialization is chunked with an await between chunks so the event loop is never blocked for
|
|
13
13
|
* more than ~SERIALIZE_CHUNK entries. A Worker Thread was considered and rejected: posting the
|