@hicaru/pi-rlm 0.1.2 → 0.1.5
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 +2 -1
- package/README.ru.md +2 -1
- package/README.zh-CN.md +2 -1
- package/package.json +4 -4
- package/src/bridge/llm-query.ts +8 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/settings.ts +2 -0
- package/src/context/repomix-context.ts +4 -2
- package/src/core/answer.ts +16 -5
- package/src/core/engine.ts +21 -16
- package/src/core/iteration.ts +7 -1
- package/src/core/pipeline.ts +1 -1
- package/src/core/types.ts +2 -0
- package/src/index.ts +41 -11
- package/src/mode/native-guards.ts +92 -0
- package/src/prompts/system.ts +149 -62
- package/src/sandbox/sandbox-manager.ts +2 -0
- package/src/sandbox/sandbox.ts +11 -1
- package/src/sandbox/worker.py +106 -12
- package/src/text/parsing.ts +2 -2
- package/src/tool/repl-tool.ts +54 -9
- package/src/ui/config-panel.ts +3 -0
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Then run `/reload` or restart Pi. Verify with `pi list` that the package appears
|
|
|
24
24
|
|
|
25
25
|
<div align="center">
|
|
26
26
|
|
|
27
|
-
<a href="https://arxiv.org/abs/2512.24601"><img src="
|
|
27
|
+
<a href="https://arxiv.org/abs/2512.24601"><img src="https://github.com/openzebra/rlm.pi/blob/master/assets/hero.png?raw=true" alt="pi-rlm"></a>
|
|
28
28
|
|
|
29
29
|
<sub>Modeled on the method in the RLM paper, reimplemented natively for Pi.</sub>
|
|
30
30
|
|
|
@@ -112,6 +112,7 @@ These functions are injected into the model's Python namespace inside the REPL:
|
|
|
112
112
|
| `context` | `list[dict]` | Repository packed as `[{"path","content","tokens"}, ...]` — the full codebase |
|
|
113
113
|
| `llm_query` | `(prompt, model=None) -> str` | One-shot sub-LLM call (worker model) |
|
|
114
114
|
| `llm_query_batched` | `(prompts, model=None) -> list[str]` | Concurrent sub-LLM calls (pool-bounded) |
|
|
115
|
+
| `llm_query_chunked` | `(text, prompt, model=None) -> list[str]` | Split large text into cap-sized chunks and fan out via sub-LLMs |
|
|
115
116
|
| `rlm_query` | `(prompt, model=None) -> str` | Recursive child RLM with its own sandbox (depth-capped) |
|
|
116
117
|
| `rlm_query_batched` | `(prompts, model=None) -> list[str]` | Concurrent recursive child RLMs |
|
|
117
118
|
| `todo` | `(action, **kwargs) -> str` | Task list: `create`/`update`/`list`/`get`/`delete`/`clear` |
|
package/README.ru.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="
|
|
3
|
+
<img src="https://github.com/openzebra/rlm.pi/blob/master/assets/hero.png?raw=true" alt="pi-rlm">
|
|
4
4
|
|
|
5
5
|
</div>
|
|
6
6
|
|
|
@@ -106,6 +106,7 @@ rm -rf ~/.pi/agent/extensions/rlm
|
|
|
106
106
|
| `context` | `list[dict]` | Репозиторий, упакованный как `[{"path","content","tokens"}, ...]` — вся кодовая база |
|
|
107
107
|
| `llm_query` | `(prompt, model=None) -> str` | Одноразовый вызов sub-LLM (worker-модель) |
|
|
108
108
|
| `llm_query_batched` | `(prompts, model=None) -> list[str]` | Параллельные вызовы sub-LLM (с ограничением пула) |
|
|
109
|
+
| `llm_query_chunked` | `(text, prompt, model=None) -> list[str]` | Дробит большой текст на части по лимиту и обрабатывает через sub-LLM |
|
|
109
110
|
| `rlm_query` | `(prompt, model=None) -> str` | Рекурсивный дочерний RLM со своей песочницей (с ограничением глубины) |
|
|
110
111
|
| `rlm_query_batched` | `(prompts, model=None) -> list[str]` | Параллельные рекурсивные дочерние RLM |
|
|
111
112
|
| `todo` | `(action, **kwargs) -> str` | Список задач: `create`/`update`/`list`/`get`/`delete`/`clear` |
|
package/README.zh-CN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="
|
|
3
|
+
<img src="https://github.com/openzebra/rlm.pi/blob/master/assets/hero.png?raw=true" alt="pi-rlm">
|
|
4
4
|
|
|
5
5
|
</div>
|
|
6
6
|
|
|
@@ -113,6 +113,7 @@ rm -rf ~/.pi/agent/extensions/rlm
|
|
|
113
113
|
| `context` | `list[dict]` | 打包为 `[{"path","content","tokens"}, ...]` 的仓库 —— 完整的代码库 |
|
|
114
114
|
| `llm_query` | `(prompt, model=None) -> str` | 单次子 LLM 调用 (worker 模型) |
|
|
115
115
|
| `llm_query_batched` | `(prompts, model=None) -> list[str]` | 并发子 LLM 调用 (池上限) |
|
|
116
|
+
| `llm_query_chunked` | `(text, prompt, model=None) -> list[str]` | 将大文本拆分为不超过上限的块并通过子 LLM 处理 |
|
|
116
117
|
| `rlm_query` | `(prompt, model=None) -> str` | 具有自有沙箱的递归子 RLM (设有深度限制) |
|
|
117
118
|
| `rlm_query_batched` | `(prompts, model=None) -> list[str]` | 并发递归子 RLM |
|
|
118
119
|
| `todo` | `(action, **kwargs) -> str` | 任务列表:`create`/`update`/`list`/`get`/`delete`/`clear` |
|
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hicaru/pi-rlm",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Save 99% tokens, Recursive Language Model (RLM) for the Pi",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "hicaru",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/
|
|
10
|
+
"url": "git+https://github.com/openzebra/rlm.pi.git"
|
|
11
11
|
},
|
|
12
|
-
"homepage": "https://github.com/
|
|
12
|
+
"homepage": "https://github.com/openzebra/rlm.pi",
|
|
13
13
|
"bugs": {
|
|
14
|
-
"url": "https://github.com/
|
|
14
|
+
"url": "https://github.com/openzebra/rlm.pi/issues"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"src/",
|
package/src/bridge/llm-query.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
|
11
11
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
13
13
|
import { modelRef, resolveModelId } from "../config/settings.ts";
|
|
14
|
+
import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
|
|
14
15
|
import type { Sampling } from "../core/types.ts";
|
|
15
16
|
import { type ChatMsg, modelComplete } from "./model.ts";
|
|
16
17
|
import { previewText } from "../text/preview.ts";
|
|
@@ -26,6 +27,8 @@ export interface LlmBridgeOptions {
|
|
|
26
27
|
readonly sampling?: Sampling;
|
|
27
28
|
readonly signal?: AbortSignal;
|
|
28
29
|
readonly onUsage?: (usage: Usage, model: Model<Api>) => void;
|
|
30
|
+
/** Parent run's remaining budget/timeout; checked before every sub-call. */
|
|
31
|
+
readonly remainingBudget?: () => RemainingResources;
|
|
29
32
|
/** Live RlmDetails reporting via onUpdate. */
|
|
30
33
|
readonly emitter?: RlmEmitter;
|
|
31
34
|
readonly parentId?: string;
|
|
@@ -49,6 +52,11 @@ export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
|
|
|
49
52
|
|
|
50
53
|
// Run one completion; report cost/tokens via `track` (a per-call or per-batch accumulator).
|
|
51
54
|
async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
|
|
55
|
+
const rem = opts.remainingBudget?.();
|
|
56
|
+
if (rem !== undefined) {
|
|
57
|
+
const limitError = checkResourceLimits(rem);
|
|
58
|
+
if (limitError !== undefined) return limitError;
|
|
59
|
+
}
|
|
52
60
|
if (prompt.length > maxPromptChars) {
|
|
53
61
|
return formatError(`sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ${maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`);
|
|
54
62
|
}
|
package/src/config/defaults.ts
CHANGED
package/src/config/settings.ts
CHANGED
|
@@ -73,6 +73,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
73
73
|
if (maxErrors !== undefined) out.maxErrors = maxErrors;
|
|
74
74
|
const orchestrator = validateBoolean(r.orchestrator);
|
|
75
75
|
if (orchestrator !== undefined) out.orchestrator = orchestrator;
|
|
76
|
+
const pipeline = validateBoolean(r.pipeline);
|
|
77
|
+
if (pipeline !== undefined) out.pipeline = pipeline;
|
|
76
78
|
const compaction = validateBoolean(r.compaction);
|
|
77
79
|
if (compaction !== undefined) out.compaction = compaction;
|
|
78
80
|
const compactionThresholdPct = validateNumber(r.compactionThresholdPct, 0);
|
|
@@ -98,6 +98,7 @@ export async function packRepository(
|
|
|
98
98
|
output: {
|
|
99
99
|
filePath: `${tmpdir()}/repomix-out-${Date.now()}.txt`,
|
|
100
100
|
style: "plain",
|
|
101
|
+
filePathStyle: "cwd-relative",
|
|
101
102
|
parsableStyle: false,
|
|
102
103
|
headerText: undefined,
|
|
103
104
|
instructionFilePath: undefined,
|
|
@@ -219,7 +220,7 @@ const MAX_LLM_LISTING_FILES = 200;
|
|
|
219
220
|
/**
|
|
220
221
|
* Produces a compact human-readable text block for the parent LLM's context window.
|
|
221
222
|
* Shows file paths and token estimates — NOT full file contents (those are too large
|
|
222
|
-
* for the context window). The LLM
|
|
223
|
+
* for the context window). The LLM inspects files via repl() over the pre-loaded context variable.
|
|
223
224
|
*/
|
|
224
225
|
export function formatForLLM(bundle: ContextBundle): string {
|
|
225
226
|
const files = bundle.files.slice(0, MAX_LLM_LISTING_FILES);
|
|
@@ -237,7 +238,8 @@ export function formatForLLM(bundle: ContextBundle): string {
|
|
|
237
238
|
listing,
|
|
238
239
|
truncated,
|
|
239
240
|
"",
|
|
240
|
-
"
|
|
241
|
+
"All file contents are pre-loaded in the REPL `context` variable — file-reading tools are disabled.",
|
|
242
|
+
"Use repl({code}) and delegate semantic reading to llm_query / llm_query_batched / llm_query_chunked.",
|
|
241
243
|
].join("\n");
|
|
242
244
|
}
|
|
243
245
|
|
package/src/core/answer.ts
CHANGED
|
@@ -37,10 +37,11 @@ export function turnHadError(results: readonly ReplResult[]): boolean {
|
|
|
37
37
|
* hist ← hist ∥ code ∥ Metadata(stdout)). */
|
|
38
38
|
const SMALL_STDOUT_LIMIT = 800;
|
|
39
39
|
const STDOUT_PREVIEW_LIMIT = 200;
|
|
40
|
+
const STDOUT_TAIL_LIMIT = 200;
|
|
40
41
|
const STDERR_LIMIT = 8_000;
|
|
41
42
|
|
|
42
43
|
/** The REPL output fed back to the model as the next user message. */
|
|
43
|
-
export function formatReplOutputs(results: readonly ReplResult[]): string {
|
|
44
|
+
export function formatReplOutputs(results: readonly ReplResult[], skippedBlocks = 0): string {
|
|
44
45
|
if (results.length === 0) {
|
|
45
46
|
return "No ```repl``` block found in your response. Write one to interact with the REPL.";
|
|
46
47
|
}
|
|
@@ -54,15 +55,18 @@ export function formatReplOutputs(results: readonly ReplResult[]): string {
|
|
|
54
55
|
parts.push(`${head}${text}${formatStderr(r)}`);
|
|
55
56
|
}
|
|
56
57
|
const body = parts.join("\n\n");
|
|
58
|
+
const skipNote = skippedBlocks > 0
|
|
59
|
+
? `\n\n[${skippedBlocks} later \`\`\`repl\`\`\` block(s) skipped because an earlier block raised — fix and re-run them]`
|
|
60
|
+
: "";
|
|
57
61
|
// Orientation hint only when the model lost output to elision — otherwise it sees everything.
|
|
58
|
-
if (!hadElision) return body
|
|
62
|
+
if (!hadElision) return `${body}${skipNote}`;
|
|
59
63
|
// The REPL namespace is persistent across blocks in a turn, so the last block's varNames reflect
|
|
60
64
|
// every variable created in any earlier block too.
|
|
61
65
|
const varNames = results.at(-1)?.varNames ?? [];
|
|
62
66
|
const hint = varNames.length > 0
|
|
63
67
|
? `REPL vars: ${varNames.join(", ")}`
|
|
64
68
|
: `No REPL vars yet — assign results to variables before printing large outputs.`;
|
|
65
|
-
return `${body}\n\n${hint}`;
|
|
69
|
+
return `${body}${skipNote}\n\n${hint}`;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
/** Stdout ≤ SMALL_STDOUT_LIMIT flows through verbatim; larger output keeps a short head + a note
|
|
@@ -71,8 +75,15 @@ function formatStdout(r: ReplResult): { text: string; elided: boolean } {
|
|
|
71
75
|
const out = r.stdout.trim();
|
|
72
76
|
if (!out) return { text: "(no stdout)", elided: false };
|
|
73
77
|
if (out.length <= SMALL_STDOUT_LIMIT) return { text: out, elided: false };
|
|
74
|
-
const
|
|
75
|
-
return {
|
|
78
|
+
const cut = out.length - STDOUT_PREVIEW_LIMIT - STDOUT_TAIL_LIMIT;
|
|
79
|
+
return {
|
|
80
|
+
text: [
|
|
81
|
+
out.slice(0, STDOUT_PREVIEW_LIMIT),
|
|
82
|
+
`[… ${cut} chars elided — full output stays in REPL vars; inspect slices: print(result[:500])]`,
|
|
83
|
+
out.slice(-STDOUT_TAIL_LIMIT),
|
|
84
|
+
].join("\n"),
|
|
85
|
+
elided: true,
|
|
86
|
+
};
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
function formatStderr(r: ReplResult): string {
|
package/src/core/engine.ts
CHANGED
|
@@ -98,6 +98,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
98
98
|
maxErrors: deps.limits?.maxErrors,
|
|
99
99
|
maxTokens: deps.limits?.maxTokens,
|
|
100
100
|
}, input.resume?.usageSeed.durationMs ?? 0);
|
|
101
|
+
const remainingBudget = (): { readonly budgetUsd?: number; readonly timeoutMs?: number } => ({
|
|
102
|
+
budgetUsd: limits.remainingBudgetUsd(),
|
|
103
|
+
timeoutMs: limits.remainingTimeoutMs(),
|
|
104
|
+
});
|
|
101
105
|
|
|
102
106
|
const llm = createLlmBridge({
|
|
103
107
|
workerModel: deps.workerModel,
|
|
@@ -114,6 +118,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
114
118
|
emitter,
|
|
115
119
|
parentId: selfReportId,
|
|
116
120
|
depth: input.depth,
|
|
121
|
+
remainingBudget,
|
|
117
122
|
});
|
|
118
123
|
const rlm = createRlmHandlers({
|
|
119
124
|
run,
|
|
@@ -122,10 +127,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
122
127
|
maxDepth: deps.config.maxDepth,
|
|
123
128
|
maxConcurrent: deps.config.maxConcurrentSubcalls,
|
|
124
129
|
parentNodeId: selfReportId,
|
|
125
|
-
remainingBudget
|
|
126
|
-
budgetUsd: limits.remainingBudgetUsd(),
|
|
127
|
-
timeoutMs: limits.remainingTimeoutMs(),
|
|
128
|
-
}),
|
|
130
|
+
remainingBudget,
|
|
129
131
|
onChildUsage: (costUsd, inputTokens, outputTokens) => {
|
|
130
132
|
limits.addRaw(costUsd, inputTokens, outputTokens);
|
|
131
133
|
},
|
|
@@ -150,7 +152,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
150
152
|
rootPrompt: input.rootPrompt,
|
|
151
153
|
context: { type: contextTypeLabel(input.context), chars: contextLength(input.context), json },
|
|
152
154
|
models: { model: model.id, worker: deps.workerModel.id },
|
|
153
|
-
meta: { maxIterations: deps.config.maxIterations, maxDepth: deps.config.maxDepth, orchestrator: deps.config.orchestrator, pipeline:
|
|
155
|
+
meta: { maxIterations: deps.config.maxIterations, maxDepth: deps.config.maxDepth, orchestrator: deps.config.orchestrator, pipeline: deps.config.pipeline },
|
|
154
156
|
};
|
|
155
157
|
persistOn = await appendRow(deps.runState.cwd, deps.runState.dir, runId, header);
|
|
156
158
|
}
|
|
@@ -166,7 +168,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
166
168
|
};
|
|
167
169
|
|
|
168
170
|
try {
|
|
169
|
-
const phaseHandlers = input.depth === 0
|
|
171
|
+
const phaseHandlers = input.depth === 0 && deps.config.pipeline
|
|
170
172
|
? {
|
|
171
173
|
advancePhase: async (phase: string, summary: string | undefined) => {
|
|
172
174
|
const outcome = validatePhaseTransition(phaseState?.current, phase);
|
|
@@ -205,6 +207,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
205
207
|
python: deps.config.python,
|
|
206
208
|
signal: deps.signal,
|
|
207
209
|
initTimeoutMs: deps.config.sandboxInitTimeoutMs,
|
|
210
|
+
maxPromptChars: deps.config.maxPromptChars,
|
|
208
211
|
handlers: { ...llm, ...rlm, ...phaseHandlers, ...interactiveHandlers },
|
|
209
212
|
});
|
|
210
213
|
|
|
@@ -219,6 +222,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
219
222
|
recursion: input.depth + 1 < deps.config.maxDepth,
|
|
220
223
|
askUserQuestion: deps.config.askUserQuestion && input.depth === 0,
|
|
221
224
|
todo: deps.config.todo,
|
|
225
|
+
pipeline: deps.config.pipeline && input.depth === 0,
|
|
226
|
+
maxPromptChars: deps.config.maxPromptChars,
|
|
222
227
|
});
|
|
223
228
|
let history: ChatMsg[] = input.resume ? input.resume.history : [{ role: "system", content: system }];
|
|
224
229
|
let pendingReplOutputs: string | undefined = input.resume?.pendingReplOutputs;
|
|
@@ -247,11 +252,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
247
252
|
if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
|
|
248
253
|
else emitter.emitTurn(i + 1, deps.config.maxIterations);
|
|
249
254
|
|
|
250
|
-
if (pendingReplOutputs) {
|
|
251
|
-
appendUserMessage(history, pendingReplOutputs);
|
|
252
|
-
pendingReplOutputs = undefined;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
255
|
if (deps.config.compaction) {
|
|
256
256
|
const compactionDeps = {
|
|
257
257
|
// Summarisation is done by the cheap worker model; the threshold stays on the
|
|
@@ -279,7 +279,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
|
|
282
|
+
if (pendingReplOutputs) {
|
|
283
|
+
appendUserMessage(history, pendingReplOutputs);
|
|
284
|
+
pendingReplOutputs = undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const gateMsg = deps.config.pipeline ? phaseGatePrompt(phaseState, completedTurns) : undefined;
|
|
283
288
|
const gateUserMsg = gateMsg ? `[${new Date().toISOString()}] ${gateMsg}` : undefined;
|
|
284
289
|
appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, gateUserMsg));
|
|
285
290
|
|
|
@@ -320,7 +325,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
320
325
|
|
|
321
326
|
limits.observe(turnHadError(turn.results));
|
|
322
327
|
history.push({ role: "assistant", content: turn.response });
|
|
323
|
-
const turnReplOutputs = formatReplOutputs(turn.results);
|
|
328
|
+
const turnReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
|
|
324
329
|
pendingReplOutputs = turnReplOutputs;
|
|
325
330
|
|
|
326
331
|
if (persistOn && runId && deps.runState) {
|
|
@@ -343,7 +348,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
343
348
|
}
|
|
344
349
|
}
|
|
345
350
|
if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
|
|
346
|
-
const finalized = result(await finalize(history, deps, limits), deps.config.maxIterations, limits, editsAcc);
|
|
351
|
+
const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits, editsAcc);
|
|
347
352
|
await recordTerminal("finalized", finalized);
|
|
348
353
|
lastAnswer = finalized.answer;
|
|
349
354
|
return finalized;
|
|
@@ -389,11 +394,11 @@ function result(answer: string, iterations: number, limits: LimitGuard, edits: P
|
|
|
389
394
|
}
|
|
390
395
|
|
|
391
396
|
/** Out of turns: ask the model for its best final answer (plain text). */
|
|
392
|
-
async function finalize(history: ChatMsg[], deps: EngineDeps, limits: LimitGuard): Promise<string> {
|
|
397
|
+
async function finalize(history: ChatMsg[], model: Model<Api>, deps: EngineDeps, limits: LimitGuard): Promise<string> {
|
|
393
398
|
const finalHistory = [...history];
|
|
394
399
|
appendUserMessage(finalHistory, FINALIZE_PROMPT);
|
|
395
400
|
const { text, usage } = await modelComplete(finalHistory, {
|
|
396
|
-
model
|
|
401
|
+
model,
|
|
397
402
|
registry: deps.registry,
|
|
398
403
|
reasoning: deps.config.smartReasoning,
|
|
399
404
|
signal: deps.signal,
|
package/src/core/iteration.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface Turn {
|
|
|
17
17
|
readonly results: readonly ReplResult[];
|
|
18
18
|
readonly usage: Usage;
|
|
19
19
|
readonly blocks: readonly string[];
|
|
20
|
+
/** Blocks not executed because an earlier block raised. */
|
|
21
|
+
readonly skippedBlocks: number;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface TurnDeps {
|
|
@@ -38,8 +40,12 @@ export async function runTurn(history: readonly ChatMsg[], sandbox: PythonSandbo
|
|
|
38
40
|
|
|
39
41
|
const blocks = findReplBlocks(text);
|
|
40
42
|
const results = new Array<ReplResult>(blocks.length);
|
|
43
|
+
let executed = 0;
|
|
41
44
|
for (let i = 0; i < blocks.length; i++) {
|
|
42
45
|
results[i] = await sandbox.exec(blocks[i]);
|
|
46
|
+
executed = i + 1;
|
|
47
|
+
if (results[i].raised) break;
|
|
43
48
|
}
|
|
44
|
-
|
|
49
|
+
results.length = executed;
|
|
50
|
+
return { response: text, results, usage, blocks, skippedBlocks: blocks.length - executed };
|
|
45
51
|
}
|
package/src/core/pipeline.ts
CHANGED
|
@@ -80,7 +80,7 @@ export function phaseGatePrompt(
|
|
|
80
80
|
): string | undefined {
|
|
81
81
|
const turns = turnsInPhase(state, completedTurns);
|
|
82
82
|
const phase = currentPhase(state);
|
|
83
|
-
if (turns >= PHASE_GATE_TURNS) {
|
|
83
|
+
if (turns >= PHASE_GATE_TURNS && turns % PHASE_GATE_TURNS === 0) {
|
|
84
84
|
const next = nextPhase(phase);
|
|
85
85
|
const hint = next
|
|
86
86
|
? ` Consider calling advance_phase("${next}") if your ${phase} work is complete.`
|
package/src/core/types.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface RlmConfig {
|
|
|
48
48
|
maxErrors?: number;
|
|
49
49
|
/** Append the orchestrator addendum to the system prompt. */
|
|
50
50
|
orchestrator: boolean;
|
|
51
|
+
/** Enable the phase pipeline (advance_phase + stall nags) at depth 0. */
|
|
52
|
+
pipeline: boolean;
|
|
51
53
|
/** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
|
|
52
54
|
compaction: boolean;
|
|
53
55
|
/** Compact when estimated history tokens reach this fraction of the model's context window. */
|
package/src/index.ts
CHANGED
|
@@ -13,10 +13,12 @@ import { postRlmGuide } from "./ui/intro.ts";
|
|
|
13
13
|
import { setRlmModeStatus } from "./ui/status.ts";
|
|
14
14
|
import { SandboxManager } from "./sandbox/sandbox-manager.ts";
|
|
15
15
|
import { packRepository, formatForLLM, serializeForSandbox } from "./context/repomix-context.ts";
|
|
16
|
-
import { buildNativeSystemPrompt } from "./prompts/system.ts";
|
|
16
|
+
import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/system.ts";
|
|
17
|
+
import { bashCommandFromInput, isFileReadingCommand, capToolResultText, BASH_BLOCK_REASON } from "./mode/native-guards.ts";
|
|
17
18
|
import { errorMessage } from "./util/errors.ts";
|
|
18
19
|
|
|
19
20
|
const BLOCKED_NATIVE_TOOLS = Object.freeze(new Set(["read", "grep"]));
|
|
21
|
+
const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls"]));
|
|
20
22
|
|
|
21
23
|
export default function rlmExtension(pi: ExtensionAPI): void {
|
|
22
24
|
// Init synchronously with defaults — ensures commands/tools/handlers register before session_start
|
|
@@ -27,6 +29,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
27
29
|
requestTimeoutMs: config.requestTimeoutMs,
|
|
28
30
|
python: config.python,
|
|
29
31
|
sandboxInitTimeoutMs: config.sandboxInitTimeoutMs,
|
|
32
|
+
maxPromptChars: config.maxPromptChars,
|
|
30
33
|
});
|
|
31
34
|
let packedContextText: string | undefined;
|
|
32
35
|
let contextPackPromise: Promise<string | undefined> | undefined;
|
|
@@ -123,11 +126,16 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
123
126
|
let contextInjected = false;
|
|
124
127
|
pi.on("context", async (event, ctx) => {
|
|
125
128
|
const filtered = event.messages.filter(
|
|
126
|
-
(message) =>
|
|
129
|
+
(message) =>
|
|
130
|
+
!(message.role === "custom" && message.customType === "rlm-intro")
|
|
131
|
+
&& !(message.role === "user" && typeof message.content === "string" && message.content === NATIVE_TURN_REMINDER),
|
|
127
132
|
);
|
|
133
|
+
if (!controller.enabled) return { messages: filtered };
|
|
128
134
|
|
|
129
|
-
|
|
130
|
-
|
|
135
|
+
type PiMessage = (typeof filtered)[number];
|
|
136
|
+
|
|
137
|
+
// Inject repository context as a compact listing (once per session)
|
|
138
|
+
if (!contextInjected) {
|
|
131
139
|
const cwd = ctx.cwd ?? process.cwd();
|
|
132
140
|
const contextText = await ensureRepositoryContext(cwd);
|
|
133
141
|
if (contextText !== undefined) {
|
|
@@ -138,16 +146,21 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
138
146
|
"Chunk context via Python, delegate to llm_query. If credits exhausted → report and stop.",
|
|
139
147
|
"",
|
|
140
148
|
].join("\n");
|
|
141
|
-
|
|
149
|
+
filtered.unshift({
|
|
142
150
|
role: "user" as const,
|
|
143
151
|
content: instruction + contextText,
|
|
144
152
|
timestamp: 0,
|
|
145
|
-
} as
|
|
146
|
-
|
|
147
|
-
return { messages: [contextMsg, ...filtered] };
|
|
153
|
+
} as PiMessage);
|
|
148
154
|
}
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
// Per-turn last-position reminder (not persisted — context hook rebuilds every request)
|
|
158
|
+
filtered.push({
|
|
159
|
+
role: "user" as const,
|
|
160
|
+
content: NATIVE_TURN_REMINDER,
|
|
161
|
+
timestamp: 0,
|
|
162
|
+
} as PiMessage);
|
|
163
|
+
|
|
151
164
|
return { messages: filtered };
|
|
152
165
|
});
|
|
153
166
|
|
|
@@ -156,10 +169,10 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
156
169
|
return { action: "continue" };
|
|
157
170
|
});
|
|
158
171
|
|
|
159
|
-
// ──
|
|
172
|
+
// ── Native mode restrictions: keep bulk file content out of root-model context ──
|
|
160
173
|
// `edit`/`write` stay unblocked so the agent modifies files through Pi's native
|
|
161
|
-
// tool flow (visible to all plugins, +/- diff preview).
|
|
162
|
-
//
|
|
174
|
+
// tool flow (visible to all plugins, +/- diff preview). File reading/searching
|
|
175
|
+
// belongs in the REPL, and bash output is capped as a backstop.
|
|
163
176
|
pi.on("tool_call", async (event) => {
|
|
164
177
|
if (!controller.enabled) return;
|
|
165
178
|
if (BLOCKED_NATIVE_TOOLS.has(event.toolName)) {
|
|
@@ -168,6 +181,23 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
168
181
|
reason: "RLM mode active. Use repl({code}) to read files and search the repository — all files are pre-loaded in the REPL `context` variable. Use `edit`/`write` for file changes. If sub-LLM credits are exhausted, report to the user.",
|
|
169
182
|
};
|
|
170
183
|
}
|
|
184
|
+
const bashCommand = event.toolName === "bash" ? bashCommandFromInput(event.input) : undefined;
|
|
185
|
+
if (bashCommand !== undefined && isFileReadingCommand(bashCommand)) {
|
|
186
|
+
return { block: true, reason: BASH_BLOCK_REASON };
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
pi.on("tool_result", async (event) => {
|
|
191
|
+
if (!controller.enabled || !CAPPED_RESULT_TOOLS.has(event.toolName)) return;
|
|
192
|
+
let changed = false;
|
|
193
|
+
const content = event.content.map((c) => {
|
|
194
|
+
if (c.type !== "text") return c;
|
|
195
|
+
const capped = capToolResultText(c.text);
|
|
196
|
+
if (capped === undefined) return c;
|
|
197
|
+
changed = true;
|
|
198
|
+
return { ...c, type: "text" as const, text: capped };
|
|
199
|
+
});
|
|
200
|
+
return changed ? { content } : undefined;
|
|
171
201
|
});
|
|
172
202
|
|
|
173
203
|
// ── Session shutdown: cleanup ──
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* native-guards — keeps bulk file content out of the root model's context in native RLM mode.
|
|
3
|
+
* Layer 1: block file-reading bash commands (steering — the redirect message re-educates).
|
|
4
|
+
* Layer 2: cap tool_result text AND repl() stdout (guarantee — bulk text physically cannot
|
|
5
|
+
* reach the root model).
|
|
6
|
+
* Layer 3: nudge the model when a repl() call printed bulk text without delegating to a sub-LLM.
|
|
7
|
+
* This is token protection, not a security boundary: a determined model can still emit small reads.
|
|
8
|
+
*/
|
|
9
|
+
import { truncateOutput } from "../text/parsing.ts";
|
|
10
|
+
|
|
11
|
+
/** Bash commands whose purpose is printing file content / searching — the REPL owns those jobs. */
|
|
12
|
+
const READER_COMMANDS: ReadonlySet<string> = Object.freeze(new Set([
|
|
13
|
+
"cat", "sed", "head", "tail", "awk", "grep", "rg", "less", "more",
|
|
14
|
+
"cut", "nl", "tac", "strings", "xxd", "od", "column",
|
|
15
|
+
]));
|
|
16
|
+
|
|
17
|
+
/** Head token of the first pipe segment of each statement (split on ;, &&, ||, newline). */
|
|
18
|
+
function statementHeads(command: string): string[] {
|
|
19
|
+
return command
|
|
20
|
+
.split(/(?:\|\||&&|;|\n)/)
|
|
21
|
+
.map((statement) => {
|
|
22
|
+
const firstSegment = statement.split("|", 1)[0] ?? "";
|
|
23
|
+
const tokens = firstSegment.trim().split(/\s+/);
|
|
24
|
+
// Skip env-var prefixes (FOO=bar cmd) and leading wrappers that don't change intent.
|
|
25
|
+
const head = tokens.find((t) => !t.includes("=") && t !== "sudo" && t !== "command" && t !== "env");
|
|
26
|
+
return head === undefined || head.length === 0 ? undefined : head.replace(/^.*\//, "");
|
|
27
|
+
})
|
|
28
|
+
.filter((h): h is string => h !== undefined);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Extract bash input.command from an unknown Pi tool input shape. */
|
|
32
|
+
export function bashCommandFromInput(input: unknown): string | undefined {
|
|
33
|
+
if (typeof input !== "object" || input === null) return undefined;
|
|
34
|
+
const command: unknown = (input as Record<string, unknown>)["command"];
|
|
35
|
+
return typeof command === "string" ? command : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True when a statement starts with a file-reading command. */
|
|
39
|
+
export function isFileReadingCommand(command: string): boolean {
|
|
40
|
+
return statementHeads(command).some((h) => READER_COMMANDS.has(h));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const BASH_BLOCK_REASON =
|
|
44
|
+
"RLM mode: reading files via bash is blocked — that dumps file content into the root model's " +
|
|
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 / " +
|
|
47
|
+
"llm_query_chunked. bash is for RUNNING things (tests, builds, git).";
|
|
48
|
+
|
|
49
|
+
/** Max chars of tool output forwarded to the root model (≈1K tokens). */
|
|
50
|
+
export const TOOL_RESULT_CAP = 4_000;
|
|
51
|
+
|
|
52
|
+
const CAP_NOTE =
|
|
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.]";
|
|
55
|
+
|
|
56
|
+
const REPL_CAP_NOTE =
|
|
57
|
+
`\n[RLM: repl() stdout capped at ${TOOL_RESULT_CAP.toLocaleString()} chars — printing bulk text ` +
|
|
58
|
+
"is useless. Keep results in REPL variables and delegate semantic reading to llm_query / " +
|
|
59
|
+
"llm_query_batched / llm_query_chunked.]";
|
|
60
|
+
|
|
61
|
+
/** Shared truncation core. Returns undefined when under the cap (leave the text untouched). */
|
|
62
|
+
function capText(text: string, note: string): string | undefined {
|
|
63
|
+
if (text.length <= TOOL_RESULT_CAP) return undefined;
|
|
64
|
+
return truncateOutput(text, TOOL_RESULT_CAP) + note;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Cap a generic tool result's text (bash / find / ls). */
|
|
68
|
+
export function capToolResultText(text: string): string | undefined {
|
|
69
|
+
return capText(text, CAP_NOTE);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Cap the model-visible portion of a repl() result (stdout / answerContent). */
|
|
73
|
+
export function capReplResultText(text: string): string | undefined {
|
|
74
|
+
return capText(text, REPL_CAP_NOTE);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Stdout size above which a repl() call with zero sub-LLM calls earns a delegation nudge. */
|
|
78
|
+
export const NUDGE_STDOUT_CHARS = 2_000;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One-line corrective nudge when a repl() call printed bulk text without delegating.
|
|
82
|
+
* Returns undefined when behavior was fine (small output, or a delegation happened).
|
|
83
|
+
* Takes primitives (no RlmSubcall dependency) so the tool layer owns the detection.
|
|
84
|
+
*/
|
|
85
|
+
export function replDelegationNudge(stdoutChars: number, delegated: boolean): string | undefined {
|
|
86
|
+
if (delegated || stdoutChars <= NUDGE_STDOUT_CHARS) return undefined;
|
|
87
|
+
return (
|
|
88
|
+
`\n[RLM: this repl() printed ${stdoutChars.toLocaleString()} chars with 0 sub-LLM calls — ` +
|
|
89
|
+
"delegate semantic reading via llm_query / llm_query_batched / llm_query_chunked instead of " +
|
|
90
|
+
"reading output yourself.]"
|
|
91
|
+
);
|
|
92
|
+
}
|