pi-rlm 0.1.0
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 +110 -0
- package/index.ts +262 -0
- package/package.json +53 -0
- package/src/backends.ts +356 -0
- package/src/engine.ts +462 -0
- package/src/prompts.ts +83 -0
- package/src/runs.ts +128 -0
- package/src/schema.ts +29 -0
- package/src/types.ts +80 -0
- package/src/utils.ts +128 -0
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# pi-rlm
|
|
2
|
+
|
|
3
|
+
Recursive Language Model (RLM) extension for [Pi coding agent](https://github.com/badlogic/pi-mono).
|
|
4
|
+
|
|
5
|
+
This extension adds an `rlm` tool that performs depth-limited recursive decomposition:
|
|
6
|
+
|
|
7
|
+
1. planner node decides `solve` vs `decompose`
|
|
8
|
+
2. child nodes recurse on subtasks
|
|
9
|
+
3. synthesizer node merges child outputs
|
|
10
|
+
|
|
11
|
+
It includes guardrails for depth, node budget, branching, and cycle detection.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install /path/to/pi-rlm
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or as a package:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi install npm:pi-rlm
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Tool API
|
|
26
|
+
|
|
27
|
+
The extension registers one tool: `rlm`.
|
|
28
|
+
|
|
29
|
+
### `op=start` (default)
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
rlm({
|
|
33
|
+
task: "Implement auth refactor and validate tests",
|
|
34
|
+
backend: "sdk", // sdk | cli | tmux
|
|
35
|
+
mode: "auto", // auto | solve | decompose
|
|
36
|
+
maxDepth: 2,
|
|
37
|
+
maxNodes: 24,
|
|
38
|
+
maxBranching: 3,
|
|
39
|
+
concurrency: 2,
|
|
40
|
+
toolsProfile: "coding", // coding | read-only
|
|
41
|
+
timeoutMs: 180000,
|
|
42
|
+
async: false
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### `op=status`
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
rlm({ op: "status", id: "a1b2c3d4" })
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
If `id` is omitted, returns recent runs.
|
|
53
|
+
|
|
54
|
+
### `op=wait`
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
rlm({ op: "wait", id: "a1b2c3d4", waitTimeoutMs: 120000 })
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### `op=cancel`
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
rlm({ op: "cancel", id: "a1b2c3d4" })
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Backend Behavior
|
|
67
|
+
|
|
68
|
+
### `backend: "sdk"` (default)
|
|
69
|
+
|
|
70
|
+
- Runs subcalls in-process via Pi SDK sessions
|
|
71
|
+
- No fresh `pi` CLI process per subcall
|
|
72
|
+
- Best default for low overhead and deterministic orchestration
|
|
73
|
+
|
|
74
|
+
### `backend: "cli"`
|
|
75
|
+
|
|
76
|
+
- Runs each subcall as a fresh `pi -p` subprocess
|
|
77
|
+
- Good isolation, easier debugging in logs
|
|
78
|
+
- Slightly higher process overhead
|
|
79
|
+
|
|
80
|
+
### `backend: "tmux"`
|
|
81
|
+
|
|
82
|
+
- Runs each subcall inside a detached tmux session
|
|
83
|
+
- Uses fresh `pi` process per subcall
|
|
84
|
+
- Useful when you specifically want tmux-level observability/control
|
|
85
|
+
|
|
86
|
+
## Artifacts
|
|
87
|
+
|
|
88
|
+
Each run writes artifacts to:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
/tmp/pi-rlm-runs/<runId>/
|
|
92
|
+
events.jsonl
|
|
93
|
+
tree.json
|
|
94
|
+
output.md
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Guardrails
|
|
98
|
+
|
|
99
|
+
- `maxDepth`: recursion depth cap
|
|
100
|
+
- `maxNodes`: total node budget
|
|
101
|
+
- `maxBranching`: child count cap per decomposition
|
|
102
|
+
- cycle detection by normalized task lineage
|
|
103
|
+
- cancellable runs (`op=cancel`)
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npm install
|
|
109
|
+
npm run typecheck
|
|
110
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { runRlmEngine } from "./src/engine";
|
|
3
|
+
import { rlmToolParamsSchema, type RlmToolParams } from "./src/schema";
|
|
4
|
+
import { RunStore } from "./src/runs";
|
|
5
|
+
import type { RunRecord, StartRunInput } from "./src/types";
|
|
6
|
+
import { truncateText } from "./src/utils";
|
|
7
|
+
|
|
8
|
+
const defaultWaitTimeoutMs = 120000;
|
|
9
|
+
const defaultNodeTimeoutMs = 180000;
|
|
10
|
+
|
|
11
|
+
export default function extension(pi: ExtensionAPI): void {
|
|
12
|
+
const runs = new RunStore();
|
|
13
|
+
|
|
14
|
+
pi.registerTool({
|
|
15
|
+
name: "rlm",
|
|
16
|
+
label: "RLM",
|
|
17
|
+
description:
|
|
18
|
+
"Recursive Language Model orchestration with depth-limited decomposition. Supports start/status/wait/cancel and sdk/cli/tmux backends.",
|
|
19
|
+
parameters: rlmToolParamsSchema,
|
|
20
|
+
async execute(_toolCallId, params: RlmToolParams, signal, onUpdate, ctx) {
|
|
21
|
+
const op = params.op ?? "start";
|
|
22
|
+
|
|
23
|
+
if (op === "start") {
|
|
24
|
+
if (!params.task || !params.task.trim()) {
|
|
25
|
+
throw new Error("'task' is required for op=start");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const input = resolveStartInput(params, ctx.cwd);
|
|
29
|
+
const progress = (line: string): void => {
|
|
30
|
+
onUpdate?.({
|
|
31
|
+
content: [{ type: "text", text: line }],
|
|
32
|
+
details: {}
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const record = runs.start(
|
|
37
|
+
input,
|
|
38
|
+
(runId, runSignal) => runRlmEngine({ ...input, runId }, ctx, runSignal, progress),
|
|
39
|
+
signal
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (input.async) {
|
|
43
|
+
return {
|
|
44
|
+
content: [
|
|
45
|
+
{
|
|
46
|
+
type: "text",
|
|
47
|
+
text: [
|
|
48
|
+
`RLM run started in background.`,
|
|
49
|
+
`run_id: ${record.id}`,
|
|
50
|
+
`backend: ${input.backend}`,
|
|
51
|
+
`mode: ${input.mode}`,
|
|
52
|
+
`depth<=${input.maxDepth} nodes<=${input.maxNodes}`
|
|
53
|
+
].join("\n")
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
details: toRunDetails(record)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const result = await record.promise;
|
|
61
|
+
const summary = truncateText(result.final, 60000);
|
|
62
|
+
|
|
63
|
+
const lines = [
|
|
64
|
+
`RLM run completed.`,
|
|
65
|
+
`run_id: ${result.runId}`,
|
|
66
|
+
`backend: ${result.backend}`,
|
|
67
|
+
`stats: nodes=${result.stats.nodesVisited}, maxDepthSeen=${result.stats.maxDepthSeen}, durationMs=${result.stats.durationMs}`,
|
|
68
|
+
`artifacts: ${result.artifacts.dir}`,
|
|
69
|
+
"",
|
|
70
|
+
summary.text
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
if (summary.truncated) {
|
|
74
|
+
lines.push("", `Full output saved to: ${result.artifacts.outputPath}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
79
|
+
details: {
|
|
80
|
+
...toRunDetails(record),
|
|
81
|
+
result
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (op === "status") {
|
|
87
|
+
if (params.id) {
|
|
88
|
+
const record = runs.get(params.id);
|
|
89
|
+
if (!record) {
|
|
90
|
+
throw new Error(`Unknown run id: ${params.id}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: "text", text: describeRecord(record) }],
|
|
95
|
+
details: toRunDetails(record)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const list = runs.list();
|
|
100
|
+
if (list.length === 0) {
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: "text", text: "No RLM runs found." }],
|
|
103
|
+
details: { runs: [] }
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const lines = ["Recent RLM runs:", ...list.slice(0, 20).map(formatRunLine)];
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
110
|
+
details: { runs: list.map(toRunDetails) }
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!params.id) {
|
|
115
|
+
throw new Error(`'id' is required for op=${op}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (op === "wait") {
|
|
119
|
+
const waitTimeoutMs = params.waitTimeoutMs ?? defaultWaitTimeoutMs;
|
|
120
|
+
const { record, done } = await runs.wait(params.id, waitTimeoutMs);
|
|
121
|
+
|
|
122
|
+
if (!done) {
|
|
123
|
+
return {
|
|
124
|
+
content: [
|
|
125
|
+
{
|
|
126
|
+
type: "text",
|
|
127
|
+
text: [
|
|
128
|
+
`Run ${record.id} is still running.`,
|
|
129
|
+
`status: ${record.status}`,
|
|
130
|
+
`wait timeout reached after ${waitTimeoutMs}ms`
|
|
131
|
+
].join("\n")
|
|
132
|
+
}
|
|
133
|
+
],
|
|
134
|
+
details: {
|
|
135
|
+
...toRunDetails(record),
|
|
136
|
+
done: false,
|
|
137
|
+
wait_status: "timeout"
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (record.status === "completed" && record.result) {
|
|
143
|
+
const summary = truncateText(record.result.final, 60000);
|
|
144
|
+
const text = [
|
|
145
|
+
`Run ${record.id} completed.`,
|
|
146
|
+
`artifacts: ${record.result.artifacts.dir}`,
|
|
147
|
+
"",
|
|
148
|
+
summary.text,
|
|
149
|
+
summary.truncated ? `\nFull output: ${record.result.artifacts.outputPath}` : ""
|
|
150
|
+
]
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.join("\n");
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
content: [{ type: "text", text }],
|
|
156
|
+
details: {
|
|
157
|
+
...toRunDetails(record),
|
|
158
|
+
done: true,
|
|
159
|
+
wait_status: "completed"
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
content: [{ type: "text", text: describeRecord(record) }],
|
|
166
|
+
details: {
|
|
167
|
+
...toRunDetails(record),
|
|
168
|
+
done: true,
|
|
169
|
+
wait_status: record.status
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (op === "cancel") {
|
|
175
|
+
const record = runs.cancel(params.id);
|
|
176
|
+
return {
|
|
177
|
+
content: [
|
|
178
|
+
{
|
|
179
|
+
type: "text",
|
|
180
|
+
text: `Cancellation requested for run ${record.id}. Current status: ${record.status}`
|
|
181
|
+
}
|
|
182
|
+
],
|
|
183
|
+
details: {
|
|
184
|
+
...toRunDetails(record),
|
|
185
|
+
cancel_applied: record.status === "running"
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
throw new Error(`Unsupported op: ${op}`);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveStartInput(params: RlmToolParams, cwd: string): StartRunInput {
|
|
196
|
+
return {
|
|
197
|
+
task: params.task ?? "",
|
|
198
|
+
backend: params.backend ?? "sdk",
|
|
199
|
+
mode: params.mode ?? "auto",
|
|
200
|
+
async: params.async ?? false,
|
|
201
|
+
model: params.model,
|
|
202
|
+
cwd: params.cwd ?? cwd,
|
|
203
|
+
toolsProfile: params.toolsProfile ?? "coding",
|
|
204
|
+
maxDepth: params.maxDepth ?? 2,
|
|
205
|
+
maxNodes: params.maxNodes ?? 24,
|
|
206
|
+
maxBranching: params.maxBranching ?? 3,
|
|
207
|
+
concurrency: params.concurrency ?? 2,
|
|
208
|
+
timeoutMs: params.timeoutMs ?? defaultNodeTimeoutMs
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function formatRunLine(record: RunRecord): string {
|
|
213
|
+
const elapsed = (record.finishedAt ?? Date.now()) - record.startedAt;
|
|
214
|
+
return `- ${record.id} | ${record.status} | ${record.input.backend} | ${elapsed}ms | task=${shorten(
|
|
215
|
+
record.input.task,
|
|
216
|
+
48
|
|
217
|
+
)}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function describeRecord(record: RunRecord): string {
|
|
221
|
+
const lines = [
|
|
222
|
+
`run_id: ${record.id}`,
|
|
223
|
+
`status: ${record.status}`,
|
|
224
|
+
`backend: ${record.input.backend}`,
|
|
225
|
+
`mode: ${record.input.mode}`,
|
|
226
|
+
`task: ${record.input.task}`,
|
|
227
|
+
`started_at: ${new Date(record.startedAt).toISOString()}`
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
if (record.finishedAt) {
|
|
231
|
+
lines.push(`finished_at: ${new Date(record.finishedAt).toISOString()}`);
|
|
232
|
+
lines.push(`duration_ms: ${record.finishedAt - record.startedAt}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (record.error) {
|
|
236
|
+
lines.push(`error: ${record.error}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (record.result) {
|
|
240
|
+
lines.push(`artifacts: ${record.result.artifacts.dir}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return lines.join("\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function toRunDetails(record: RunRecord): Record<string, unknown> {
|
|
247
|
+
return {
|
|
248
|
+
contract_version: "rlm.v1",
|
|
249
|
+
run_id: record.id,
|
|
250
|
+
status: record.status,
|
|
251
|
+
input: record.input,
|
|
252
|
+
created_at: record.createdAt,
|
|
253
|
+
started_at: record.startedAt,
|
|
254
|
+
finished_at: record.finishedAt,
|
|
255
|
+
error: record.error
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function shorten(text: string, maxChars: number): string {
|
|
260
|
+
if (text.length <= maxChars) return text;
|
|
261
|
+
return `${text.slice(0, maxChars - 3)}...`;
|
|
262
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-rlm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Recursive Language Model (RLM) extension for Pi coding agent",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.ts",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi-coding-agent",
|
|
12
|
+
"rlm",
|
|
13
|
+
"recursive-language-model",
|
|
14
|
+
"subagent"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/manojlds/pi-rlm.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/manojlds/pi-rlm",
|
|
21
|
+
"bugs": "https://github.com/manojlds/pi-rlm/issues",
|
|
22
|
+
"files": [
|
|
23
|
+
"index.ts",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"provenance": true
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"pack:check": "npm pack --dry-run",
|
|
34
|
+
"check": "npm run typecheck && npm run pack:check"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@mariozechner/pi-ai": "*",
|
|
38
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
39
|
+
"@sinclair/typebox": "*"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@mariozechner/pi-ai": "^0.57.1",
|
|
43
|
+
"@mariozechner/pi-coding-agent": "^0.57.1",
|
|
44
|
+
"@sinclair/typebox": "^0.34.41",
|
|
45
|
+
"@types/node": "^24.0.0",
|
|
46
|
+
"typescript": "^5.8.3"
|
|
47
|
+
},
|
|
48
|
+
"pi": {
|
|
49
|
+
"extensions": [
|
|
50
|
+
"./index.ts"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
}
|