@hicaru/pi-rlm 0.1.8 → 0.2.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 +22 -19
- package/package.json +2 -1
- package/src/bridge/library.ts +93 -15
- package/src/bridge/llm-query.ts +60 -36
- package/src/bridge/rlm-query.ts +63 -79
- package/src/commands/rlm-config.ts +8 -8
- package/src/commands/rlm.ts +48 -12
- package/src/config/settings.ts +33 -3
- package/src/context/library-context.ts +209 -22
- package/src/context/repomix-context.ts +7 -58
- package/src/core/answer.ts +5 -13
- package/src/core/artifacts.ts +4 -3
- package/src/core/critique.ts +92 -0
- package/src/core/engine.ts +94 -299
- package/src/core/gates.ts +33 -4
- package/src/core/limits.ts +19 -1
- package/src/core/pipeline-handlers.ts +319 -0
- package/src/core/pipeline.ts +40 -15
- package/src/core/types.ts +26 -30
- package/src/index.ts +36 -26
- package/src/mode/native-guards.ts +2 -2
- package/src/mode/rlm-mode.ts +8 -11
- package/src/prompts/phases.ts +18 -39
- package/src/prompts/system.ts +167 -64
- package/src/prompts/user.ts +1 -5
- package/src/sandbox/protocol.ts +5 -17
- package/src/sandbox/sandbox-manager.ts +5 -5
- package/src/sandbox/sandbox.ts +67 -27
- package/src/sandbox/worker.py +534 -48
- package/src/state/paths.ts +1 -1
- package/src/state/reads.ts +12 -4
- package/src/state/resume.ts +26 -25
- package/src/state/rows.ts +2 -2
- package/src/text/parsing.ts +0 -6
- package/src/text/tokens.ts +7 -1
- package/src/tool/repl-details.ts +2 -3
- package/src/tool/repl-tool.ts +132 -337
- package/src/tool/rlm-aggregator.ts +7 -7
- package/src/tool/rlm-details.ts +6 -13
- package/src/tool/rlm-events.ts +14 -11
- package/src/tool/rlm-tool.ts +20 -38
- package/src/tool/subcall-render.ts +61 -9
- package/src/tool/subcall-store.ts +4 -2
- package/src/ui/config-panel.ts +43 -23
- package/src/ui/intro.ts +2 -1
- package/src/ui/status.ts +8 -5
- package/src/ui/theme-adapter.ts +36 -0
- package/src/ui/theme.ts +0 -25
- package/src/mode/input-router.ts +0 -23
- package/src/registry/edit-registry.ts +0 -22
- package/src/text/edits.ts +0 -164
- package/src/tool/apply-edits-tool.ts +0 -295
package/README.md
CHANGED
|
@@ -100,8 +100,8 @@ sub-LLM calls, hence the name.
|
|
|
100
100
|
|
|
101
101
|
While a run is active, a **live tree** shows the root orchestrator and every sub-LLM /
|
|
102
102
|
recursive child with status, model, cost, tokens, and duration. The final answer is posted
|
|
103
|
-
to the chat as markdown
|
|
104
|
-
|
|
103
|
+
to the chat as markdown. File changes use Pi's native `edit` / `write` tools (with their
|
|
104
|
+
built-in diff preview).
|
|
105
105
|
|
|
106
106
|
## Sandbox API
|
|
107
107
|
|
|
@@ -117,10 +117,9 @@ These functions are injected into the model's Python namespace inside the REPL:
|
|
|
117
117
|
| `rlm_query_batched` | `(prompts, model=None) -> list[str]` | Concurrent recursive child RLMs |
|
|
118
118
|
| `todo` | `(action, **kwargs) -> str` | Task list: `create`/`update`/`list`/`get`/`delete`/`clear` |
|
|
119
119
|
| `ask_user_question` | `(questions) -> list[dict]` | Ask the user structured questions (depth 0 only) |
|
|
120
|
-
| `load_library` | `(source) -> dict \| str` |
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
123
|
-
| `advance_phase` | `(phase, summary=None) -> str` | Advance one step in order `clarify → research → blueprint → implement → validate` (clarify skipped when `askUserQuestion` is off). **Engine-gated** on the latest artifact + interview rounds. Rejected transitions return the gate error. |
|
|
120
|
+
| `load_library` | `(source) -> dict \| str` | Append an external dir, file, or git URL into `context` under `lib/<id>/` |
|
|
121
|
+
| `save_artifact` | `(kind, content) -> str` | Persist a stage artifact (`clarification` / `research` / `plan` / `validation`) under `.rlm/artifacts/` (root depth only). Returns preflight gate critique. |
|
|
122
|
+
| `advance_phase` | `(phase, summary=None) -> str` | Advance one step in order `clarify → research → blueprint → validate` (clarify skipped when `askUserQuestion` is off). **Engine-gated** on the latest artifact + interview rounds. Rejected transitions return the gate error. |
|
|
124
123
|
| `SHOW_VARS` | `() -> str` | List currently defined variables & their types |
|
|
125
124
|
| `answer` | `dict` | Set `answer["content"]=...; answer["ready"]=True` to finalize |
|
|
126
125
|
|
|
@@ -130,27 +129,31 @@ When the task needs an **external library, another source tree, or standalone do
|
|
|
130
129
|
not in the packed repo `context`, the model calls `load_library(source)` mid-run:
|
|
131
130
|
|
|
132
131
|
```python
|
|
133
|
-
info = load_library("../some-lib")
|
|
134
|
-
info = load_library("docs/api.md")
|
|
135
|
-
info = load_library("https://github.com/x/y.git")
|
|
136
|
-
#
|
|
137
|
-
#
|
|
132
|
+
info = load_library("../some-lib") # local directory → packed + appended
|
|
133
|
+
info = load_library("docs/api.md") # single file → one entry in context
|
|
134
|
+
info = load_library("https://github.com/x/y.git") # shallow clone, then pack + append
|
|
135
|
+
# Files land in the SAME `context` list under lib/<source_id>/…
|
|
136
|
+
# info == {"source_id", "path_prefix", "files", "chars", "context_len", "already_loaded", …}
|
|
137
|
+
lib = [f for f in context if f["path"].startswith(info["path_prefix"])]
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
`/rlm-config` → **Library loader** (`libraryLoader`,
|
|
142
|
-
persistence, each
|
|
140
|
+
There is no `context_1` / `context_2` — only `context`. Paths are namespaced so multiple
|
|
141
|
+
libraries do not collide. Toggle via `/rlm-config` → **Library loader** (`libraryLoader`,
|
|
142
|
+
default on). On headless runs with persistence, each load writes a resume sidecar
|
|
143
|
+
(`context.<N>.json`) that is **merged back into `context`** on resume.
|
|
143
144
|
|
|
144
145
|
### Artifact-gated pipeline (opt-in via `pipeline: true`)
|
|
145
146
|
|
|
146
147
|
When enabled at root depth:
|
|
147
148
|
|
|
148
149
|
1. **Goal capture** — the brief is written verbatim to `.rlm/artifacts/goal/goal-<ts>.md` with a pre-run dirty-tree baseline.
|
|
149
|
-
2. **Stages** — `clarify → research → blueprint →
|
|
150
|
+
2. **Stages** — `clarify → research → blueprint → validate` (**read-only** — produces a validated plan; does not write code). Each produces a durable markdown artifact with frontmatter contracts; chat history is **reset** at every phase boundary (artifacts are the only channel; REPL vars persist).
|
|
150
151
|
3. **Clarify (intake)** — interviews the user via `ask_user_question` (intent first, then evidence-confirmed decisions). Writes `.rlm/artifacts/clarifications/*` with `decisions_count` / `open_questions_count`. Engine gate: **≥1 serviced ask round** + artifact contract. When **`askUserQuestion` is off**, clarify is skipped and the run starts at research.
|
|
151
|
-
4. **Gates (TypeScript, never LLM judgment)** — `status: ready`; clarify structure; plan `phases:` ≡ fence-aware `## Phase N:` headings; every `file:line` citation resolves; validate carries `blockers_count` + `verdict`.
|
|
152
|
-
5. **
|
|
153
|
-
6. **Corrective loop** — `blockers_count > 0` re-enters blueprint, bounded by `maxBackwardJumps` (default 2).
|
|
152
|
+
4. **Gates (TypeScript, never LLM judgment)** — `status: ready`; clarify structure; plan `phases:` ≡ fence-aware `## Phase N:` headings; every `file:line` citation resolves; validate carries `blockers_count` + `verdict`. Preflight critique runs on every `save_artifact`.
|
|
153
|
+
5. **Validate** — adversarial plan review against the tree (not a post-implementation diff check). Final answer is the validated plan.
|
|
154
|
+
6. **Corrective loop** — `blockers_count > 0` re-enters blueprint (superseded plan kept for context), bounded by `maxBackwardJumps` (default 2).
|
|
155
|
+
|
|
156
|
+
Native RLM mode authors file changes with Pi's native `edit` / `write` tools. Sub-LLMs extract and locate; they never ship code.
|
|
154
157
|
|
|
155
158
|
## Settings (`/rlm-config`)
|
|
156
159
|
|
|
@@ -167,7 +170,7 @@ When enabled at root depth:
|
|
|
167
170
|
| Token ceiling | none | total input+output token cap for the whole recursive tree |
|
|
168
171
|
| Max consecutive errors | `5` | stop after N consecutive failing turns (none = off) |
|
|
169
172
|
| Orchestrator addendum | on | divide-and-conquer guidance in the root system prompt |
|
|
170
|
-
| Phase pipeline | off | artifact-gated clarify→research→blueprint→
|
|
173
|
+
| Phase pipeline | off | artifact-gated clarify→research→blueprint→validate (read-only plan pipeline) |
|
|
171
174
|
| Max validate→blueprint loops | `2` | bounded corrective re-entries when validation reports blockers |
|
|
172
175
|
| Ask user question | on | when pipeline is on, enables clarify intake; when off, pipeline starts at research |
|
|
173
176
|
| Trajectory compaction | on (0.65) | summarize old turns when history nears the context window |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hicaru/pi-rlm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Save 99% tokens, Recursive Language Model (RLM) for the Pi",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
29
|
"check": "tsc --noEmit",
|
|
30
|
+
"test": "bun run test/smoke.ts",
|
|
30
31
|
"prepublishOnly": "npm run check"
|
|
31
32
|
},
|
|
32
33
|
"pi": {
|
package/src/bridge/library.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared load_library handler for headless engine and native repl() mode.
|
|
3
3
|
*
|
|
4
|
-
* Host
|
|
5
|
-
*
|
|
4
|
+
* Host packs the source via resolveLibrarySource (namespaced under lib/<id>/),
|
|
5
|
+
* assigns a resume-sidecar index, and returns the payload for the worker to
|
|
6
|
+
* append into the single `context` list.
|
|
7
|
+
*
|
|
8
|
+
* Idempotency is host-side: re-loading a source that was already packed does
|
|
9
|
+
* not consume an index, write a sidecar, or re-clone/pack. That keeps resume
|
|
10
|
+
* trails free of duplicate library slots.
|
|
6
11
|
*
|
|
7
12
|
* Late-bound deps (getCwd / getEmitter) keep a single handler closure correct
|
|
8
13
|
* across native repl() calls — getOrCreate only installs handlers at spawn.
|
|
@@ -10,7 +15,10 @@
|
|
|
10
15
|
|
|
11
16
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
12
17
|
import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
|
|
13
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
libraryNamespace,
|
|
20
|
+
resolveLibrarySource,
|
|
21
|
+
} from "../context/library-context.ts";
|
|
14
22
|
import { previewText } from "../text/preview.ts";
|
|
15
23
|
|
|
16
24
|
export interface LibraryBridgeOpts {
|
|
@@ -23,22 +31,35 @@ export interface LibraryBridgeOpts {
|
|
|
23
31
|
readonly getEmitter?: () => RlmEmitter | null | undefined;
|
|
24
32
|
readonly parentId?: string;
|
|
25
33
|
readonly signal?: AbortSignal;
|
|
26
|
-
/** First
|
|
34
|
+
/** First resume-sidecar index (slot 0 = repo). Resume passes 1 + max restored. */
|
|
27
35
|
readonly startIndex: number;
|
|
36
|
+
/**
|
|
37
|
+
* Prefixes already present in context (e.g. restored from sidecars).
|
|
38
|
+
* Seeded so re-load after resume is still a no-op without re-packing.
|
|
39
|
+
*/
|
|
40
|
+
readonly loadedPrefixes?: readonly string[];
|
|
28
41
|
/** Post-load hook — the engine writes the resume sidecar here; native mode omits it. */
|
|
29
42
|
readonly onLoaded?: (index: number, payload: unknown) => void | Promise<void>;
|
|
30
43
|
}
|
|
31
44
|
|
|
32
45
|
export interface LibraryHandlerBundle {
|
|
33
46
|
readonly handlers: Pick<SubLlmHandlers, "loadLibrary">;
|
|
34
|
-
/** Reset the
|
|
47
|
+
/** Reset the sidecar index counter (call when the sandbox is discarded and will re-spawn). */
|
|
35
48
|
readonly reset: () => void;
|
|
49
|
+
/** Prefixes loaded in this sandbox lifetime (for tests). */
|
|
50
|
+
readonly loadedPrefixes: () => ReadonlySet<string>;
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
export function buildLibraryHandler(opts: LibraryBridgeOpts): LibraryHandlerBundle {
|
|
39
54
|
let nextIndex = opts.startIndex;
|
|
55
|
+
/** Prefixes already loaded in this sandbox — mirrors the worker's context state. */
|
|
56
|
+
const loaded = new Set<string>(opts.loadedPrefixes ?? []);
|
|
40
57
|
return {
|
|
41
|
-
reset: () => {
|
|
58
|
+
reset: () => {
|
|
59
|
+
nextIndex = opts.startIndex;
|
|
60
|
+
loaded.clear();
|
|
61
|
+
},
|
|
62
|
+
loadedPrefixes: () => loaded,
|
|
42
63
|
handlers: {
|
|
43
64
|
async loadLibrary(source, depth) {
|
|
44
65
|
const emitter = opts.getEmitter?.() ?? opts.emitter;
|
|
@@ -53,19 +74,76 @@ export function buildLibraryHandler(opts: LibraryBridgeOpts): LibraryHandlerBund
|
|
|
53
74
|
depth,
|
|
54
75
|
});
|
|
55
76
|
try {
|
|
77
|
+
// Cheap pre-check BEFORE cloning/packing: same namespace ⇒ nothing to do.
|
|
78
|
+
const { sourceId: preId, pathPrefix: prefix } = libraryNamespace(source, cwd);
|
|
79
|
+
if (loaded.has(prefix)) {
|
|
80
|
+
if (id) {
|
|
81
|
+
emitter?.emitSubcallUpdated({
|
|
82
|
+
id,
|
|
83
|
+
status: "done",
|
|
84
|
+
resultPreview: `already loaded (${prefix}*)`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
// No index consumed, no sidecar written — resume stays consistent.
|
|
88
|
+
return {
|
|
89
|
+
payload: Object.freeze([]),
|
|
90
|
+
index: -1,
|
|
91
|
+
files: 0,
|
|
92
|
+
chars: 0,
|
|
93
|
+
sourceId: preId,
|
|
94
|
+
pathPrefix: prefix,
|
|
95
|
+
alreadyLoaded: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
56
99
|
const resolved = await resolveLibrarySource(source, cwd, opts.signal);
|
|
57
100
|
if (!resolved.ok) throw new Error(resolved.error);
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
101
|
+
const { payload, files, chars, sourceId, pathPrefix } = resolved.value;
|
|
102
|
+
|
|
103
|
+
// Race: another concurrent load of the same prefix finished while we packed.
|
|
104
|
+
if (loaded.has(pathPrefix)) {
|
|
105
|
+
if (id) {
|
|
106
|
+
emitter?.emitSubcallUpdated({
|
|
107
|
+
id,
|
|
108
|
+
status: "done",
|
|
109
|
+
resultPreview: `already loaded (${pathPrefix}*)`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
payload: Object.freeze([]),
|
|
114
|
+
index: -1,
|
|
115
|
+
files: 0,
|
|
116
|
+
chars: 0,
|
|
117
|
+
sourceId,
|
|
118
|
+
pathPrefix,
|
|
119
|
+
alreadyLoaded: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Increment only after a successful sidecar write (or when no hook is set).
|
|
124
|
+
const index = nextIndex;
|
|
125
|
+
if (opts.onLoaded) {
|
|
126
|
+
await opts.onLoaded(index, payload);
|
|
127
|
+
}
|
|
128
|
+
nextIndex = index + 1;
|
|
129
|
+
loaded.add(pathPrefix);
|
|
130
|
+
|
|
131
|
+
if (id) {
|
|
132
|
+
emitter?.emitSubcallUpdated({
|
|
133
|
+
id,
|
|
134
|
+
status: "done",
|
|
135
|
+
resultPreview:
|
|
136
|
+
`+${files} file(s) → context (${pathPrefix}*, ${chars.toLocaleString()} chars)`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
64
139
|
return {
|
|
65
|
-
payload
|
|
140
|
+
payload,
|
|
66
141
|
index,
|
|
67
|
-
files
|
|
68
|
-
chars
|
|
142
|
+
files,
|
|
143
|
+
chars,
|
|
144
|
+
sourceId,
|
|
145
|
+
pathPrefix,
|
|
146
|
+
alreadyLoaded: false,
|
|
69
147
|
};
|
|
70
148
|
} catch (err) {
|
|
71
149
|
if (id) emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
|
package/src/bridge/llm-query.ts
CHANGED
|
@@ -5,88 +5,112 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Caps enforce the divide-and-conquer budget from the RLM method: per-prompt size and batch
|
|
7
7
|
* fan-out are bounded, and batches run through a fixed-size concurrency pool.
|
|
8
|
+
*
|
|
9
|
+
* Every input that can change between calls (worker model, emitter, parent node, depth,
|
|
10
|
+
* remaining budget) is an accessor, so a single bridge instance serves both the headless
|
|
11
|
+
* engine — which binds them once per run — and the native `repl` tool, which swaps them per
|
|
12
|
+
* invocation without recreating the sandbox.
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
11
16
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
12
17
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
13
|
-
import {
|
|
18
|
+
import { displayModelRef, resolveModelId } from "../config/settings.ts";
|
|
14
19
|
import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
|
|
15
20
|
import type { Sampling } from "../core/types.ts";
|
|
16
21
|
import { type ChatMsg, modelComplete } from "./model.ts";
|
|
17
22
|
import { previewText } from "../text/preview.ts";
|
|
18
|
-
import { formatError, isErrorText } from "../util/errors.ts";
|
|
23
|
+
import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
|
|
19
24
|
import { mapPool } from "../util/concurrency.ts";
|
|
20
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The config slice this bridge reads. Structurally satisfied by `RlmConfig`, and re-read on
|
|
28
|
+
* every call so `/rlm-config` changes take effect without rebuilding the sandbox.
|
|
29
|
+
*/
|
|
30
|
+
export interface LlmBridgeConfig {
|
|
31
|
+
readonly maxPromptChars: number;
|
|
32
|
+
readonly maxConcurrentSubcalls: number;
|
|
33
|
+
readonly subSystemPrompt?: string;
|
|
34
|
+
readonly subSampling?: Sampling;
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
export interface LlmBridgeOptions {
|
|
22
|
-
|
|
38
|
+
/** Resolved per call so provider/config changes between calls are picked up. */
|
|
39
|
+
readonly workerModel: () => Model<Api>;
|
|
23
40
|
readonly registry: ModelRegistry;
|
|
24
|
-
readonly
|
|
25
|
-
readonly maxPromptChars?: number;
|
|
26
|
-
readonly maxConcurrent?: number;
|
|
27
|
-
readonly sampling?: Sampling;
|
|
41
|
+
readonly config: () => LlmBridgeConfig;
|
|
28
42
|
readonly signal?: AbortSignal;
|
|
29
43
|
readonly onUsage?: (usage: Usage, model: Model<Api>) => void;
|
|
30
44
|
/** Parent run's remaining budget/timeout; checked before every sub-call. */
|
|
31
|
-
readonly remainingBudget?: () => RemainingResources;
|
|
32
|
-
/** Live RlmDetails reporting
|
|
33
|
-
readonly emitter?: RlmEmitter;
|
|
34
|
-
readonly parentId?: string;
|
|
35
|
-
readonly depth?: number;
|
|
45
|
+
readonly remainingBudget?: () => RemainingResources | undefined;
|
|
46
|
+
/** Live RlmDetails reporting target, resolved per call. */
|
|
47
|
+
readonly emitter?: () => RlmEmitter | undefined;
|
|
48
|
+
readonly parentId?: () => string | undefined;
|
|
49
|
+
readonly depth?: () => number;
|
|
36
50
|
}
|
|
37
51
|
|
|
38
|
-
|
|
39
|
-
const
|
|
52
|
+
/** Provider failures that a smaller batch or a cheaper model might get past. */
|
|
53
|
+
const RETRYABLE_HINT = /credit|402|payment|quota|rate.limit/i;
|
|
40
54
|
|
|
41
55
|
export interface LlmBridge {
|
|
42
56
|
llmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
|
|
43
|
-
llmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
|
|
57
|
+
llmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Batch outcome summary, or undefined when every prompt succeeded. */
|
|
61
|
+
function batchError(failed: number, total: number): string | undefined {
|
|
62
|
+
if (failed === 0) return undefined;
|
|
63
|
+
return failed === total
|
|
64
|
+
? `all ${total} sub-calls failed — reduce batch size or try llm_query individually`
|
|
65
|
+
: `${failed}/${total} sub-calls failed`;
|
|
44
66
|
}
|
|
45
67
|
|
|
46
68
|
export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
|
|
47
|
-
const maxPromptChars = opts.maxPromptChars ?? DEFAULT_MAX_PROMPT_CHARS;
|
|
48
|
-
const maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
49
|
-
const { emitter } = opts;
|
|
50
69
|
const displayModel = (model: string | null): string =>
|
|
51
|
-
|
|
70
|
+
displayModelRef(opts.registry, model, opts.workerModel());
|
|
52
71
|
|
|
53
72
|
// Run one completion; report cost/tokens via `track` (a per-call or per-batch accumulator).
|
|
54
73
|
async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
|
|
74
|
+
const config = opts.config();
|
|
55
75
|
const rem = opts.remainingBudget?.();
|
|
56
76
|
if (rem !== undefined) {
|
|
57
77
|
const limitError = checkResourceLimits(rem);
|
|
58
78
|
if (limitError !== undefined) return limitError;
|
|
59
79
|
}
|
|
60
|
-
if (prompt.length > maxPromptChars) {
|
|
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.`);
|
|
80
|
+
if (prompt.length > config.maxPromptChars) {
|
|
81
|
+
return formatError(`sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ${config.maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`);
|
|
62
82
|
}
|
|
63
83
|
const resolved = model ? resolveModelId(opts.registry, model) : undefined;
|
|
64
84
|
if (model && !resolved) return formatError(`unknown model override '${model}'`);
|
|
85
|
+
const target = resolved ?? opts.workerModel();
|
|
65
86
|
try {
|
|
66
87
|
const messages: ChatMsg[] = [{ role: "user", content: prompt }];
|
|
67
88
|
const res = await modelComplete(messages, {
|
|
68
|
-
model:
|
|
89
|
+
model: target,
|
|
69
90
|
registry: opts.registry,
|
|
70
|
-
system:
|
|
71
|
-
maxTokens:
|
|
72
|
-
temperature:
|
|
73
|
-
reasoning:
|
|
91
|
+
system: config.subSystemPrompt,
|
|
92
|
+
maxTokens: config.subSampling?.maxTokens,
|
|
93
|
+
temperature: config.subSampling?.temperature,
|
|
94
|
+
reasoning: config.subSampling?.reasoning,
|
|
74
95
|
signal: opts.signal,
|
|
75
96
|
});
|
|
76
|
-
opts.onUsage?.(res.usage,
|
|
97
|
+
opts.onUsage?.(res.usage, target);
|
|
77
98
|
track(res.usage);
|
|
78
99
|
return res.text;
|
|
79
100
|
} catch (err) {
|
|
80
|
-
|
|
101
|
+
const msg = errorMessage(err);
|
|
102
|
+
const hint = RETRYABLE_HINT.test(msg) ? " — try smaller batches or individual llm_query calls" : "";
|
|
103
|
+
return formatError(`${msg}${hint}`);
|
|
81
104
|
}
|
|
82
105
|
}
|
|
83
106
|
|
|
84
107
|
return {
|
|
85
108
|
async llmQuery(prompt, model) {
|
|
109
|
+
const emitter = opts.emitter?.();
|
|
86
110
|
const id = emitter?.emitSubcallCreated({
|
|
87
|
-
kind: "llm", parentId: opts.parentId, label: "llm_query",
|
|
111
|
+
kind: "llm", parentId: opts.parentId?.(), label: "llm_query",
|
|
88
112
|
model: displayModel(model), args: `prompt: ${previewText(prompt)}`,
|
|
89
|
-
depth: opts.depth ?? 0,
|
|
113
|
+
depth: opts.depth?.() ?? 0,
|
|
90
114
|
});
|
|
91
115
|
let cost = 0;
|
|
92
116
|
let tokens = 0;
|
|
@@ -103,28 +127,28 @@ export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
|
|
|
103
127
|
},
|
|
104
128
|
|
|
105
129
|
async llmQueryBatched(prompts, model) {
|
|
130
|
+
const emitter = opts.emitter?.();
|
|
106
131
|
const id = emitter?.emitSubcallCreated({
|
|
107
|
-
kind: "batch", parentId: opts.parentId, label: `llm_query ×${prompts.length}`,
|
|
132
|
+
kind: "batch", parentId: opts.parentId?.(), label: `llm_query ×${prompts.length}`,
|
|
108
133
|
model: displayModel(model), args: `prompt: ${previewText(prompts[0] ?? "")}`,
|
|
109
|
-
depth: opts.depth ?? 0,
|
|
134
|
+
depth: opts.depth?.() ?? 0,
|
|
110
135
|
});
|
|
111
136
|
let cost = 0;
|
|
112
137
|
let tokens = 0;
|
|
113
|
-
const out = await mapPool(prompts,
|
|
138
|
+
const out = await mapPool(prompts, opts.config().maxConcurrentSubcalls, (p) =>
|
|
114
139
|
complete1(p, model, (u) => {
|
|
115
140
|
cost += u.cost.total;
|
|
116
141
|
tokens += u.totalTokens;
|
|
117
142
|
}),
|
|
118
143
|
);
|
|
119
144
|
const failed = out.filter(isErrorText).length;
|
|
120
|
-
const error = failed
|
|
121
|
-
? `all ${out.length} sub-calls failed`
|
|
122
|
-
: failed > 0 ? `${failed}/${out.length} sub-calls failed` : undefined;
|
|
145
|
+
const error = batchError(failed, out.length);
|
|
123
146
|
const firstPreview = previewText(out[0] ?? "");
|
|
124
147
|
const resultPreview = out.length > 1 ? `${firstPreview} (+${out.length - 1} more)` : firstPreview;
|
|
125
148
|
if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
|
|
126
149
|
status: error ? "error" : "done", costUsd: cost, tokens,
|
|
127
150
|
resultPreview, detail: error,
|
|
151
|
+
failedCount: failed, totalCount: out.length,
|
|
128
152
|
});
|
|
129
153
|
return out;
|
|
130
154
|
},
|
package/src/bridge/rlm-query.ts
CHANGED
|
@@ -5,120 +5,104 @@
|
|
|
5
5
|
* depth cap it degrades to a plain `llm_query` (ported from rlm/core/rlm.py `_subcall`). The
|
|
6
6
|
* concurrency pool bounds parallel children for `rlm_query_batched`.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* As with the llm bridge, everything that can change between calls is an accessor, so the
|
|
9
|
+
* headless engine (which binds them once per run) and the native `repl` tool (which swaps them
|
|
10
|
+
* per invocation) share one implementation.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import type { RlmResult, RunRlm } from "../core/types.ts";
|
|
13
14
|
import type { LlmBridge } from "./llm-query.ts";
|
|
14
15
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
15
|
-
import { checkResourceLimits } from "../core/resource-limits.ts";
|
|
16
|
-
import { formatError } from "../util/errors.ts";
|
|
16
|
+
import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
|
|
17
|
+
import { errorMessage, formatError } from "../util/errors.ts";
|
|
17
18
|
import { mapPool } from "../util/concurrency.ts";
|
|
19
|
+
import { previewText } from "../text/preview.ts";
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
readonly
|
|
22
|
-
readonly
|
|
23
|
-
readonly label?: string;
|
|
24
|
-
readonly model?: string | null;
|
|
21
|
+
/** The config slice this bridge reads; structurally satisfied by `RlmConfig`. */
|
|
22
|
+
export interface RlmBridgeConfig {
|
|
23
|
+
readonly maxDepth: number;
|
|
24
|
+
readonly maxConcurrentSubcalls: number;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export interface RlmHandlers {
|
|
28
28
|
rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
|
|
29
|
-
rlmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
|
|
30
|
-
/** Full child-run result (answer + edits + usage) for engine-driven fanout. */
|
|
31
|
-
childRun(input: ChildRunInput): Promise<RlmResult>;
|
|
29
|
+
rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
export interface RlmBridgeOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Spawns one child run. The engine passes its own `run` (self-recursion); the native tool
|
|
35
|
+
* passes a closure that builds a child engine bound to the current invocation's emitter.
|
|
36
|
+
*/
|
|
35
37
|
readonly run: RunRlm;
|
|
36
38
|
readonly llm: LlmBridge;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
readonly
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
readonly config: () => RlmBridgeConfig;
|
|
40
|
+
/** "provider/id" shown on the sub-call node; see `displayModelRef` in config/settings.ts. */
|
|
41
|
+
readonly modelLabel?: (override: string | null) => string;
|
|
42
|
+
/** Live RlmDetails reporting target, resolved per call. */
|
|
43
|
+
readonly emitter: () => RlmEmitter | undefined;
|
|
44
|
+
/** Parent subcall ID that this run is attached under, resolved per call. */
|
|
45
|
+
readonly parentNodeId?: () => string | undefined;
|
|
43
46
|
/** Returns the parent's remaining budget/timeout for seeding child runs. */
|
|
44
|
-
readonly remainingBudget?: () =>
|
|
47
|
+
readonly remainingBudget?: () => RemainingResources | undefined;
|
|
45
48
|
/** Called with a child run's total cost/tokens so the parent LimitGuard debits it. */
|
|
46
49
|
readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
function emptyResult(answer: string): RlmResult {
|
|
50
|
-
return {
|
|
51
|
-
answer,
|
|
52
|
-
edits: [],
|
|
53
|
-
iterations: 0,
|
|
54
|
-
costUsd: 0,
|
|
55
|
-
inputTokens: 0,
|
|
56
|
-
outputTokens: 0,
|
|
57
|
-
durationMs: 0,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
52
|
export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
|
|
62
|
-
|
|
63
|
-
|
|
53
|
+
/**
|
|
54
|
+
* One child spawn. `childDepth` is the absolute depth the child will run at.
|
|
55
|
+
* Never throws: failures come back as "Error: ..." strings, matching the sandbox contract.
|
|
56
|
+
*/
|
|
57
|
+
async function child(prompt: string, model: string | null, childDepth: number): Promise<string> {
|
|
64
58
|
// At the cap, a child RLM would just be an LM — short-circuit to a one-shot llm_query.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const answer = await opts.llm.llmQuery(
|
|
68
|
-
input.rootPrompt || String(input.context),
|
|
69
|
-
input.model ?? null,
|
|
70
|
-
childDepth - 1,
|
|
71
|
-
);
|
|
72
|
-
return emptyResult(answer);
|
|
59
|
+
if (childDepth >= opts.config().maxDepth) {
|
|
60
|
+
return opts.llm.llmQuery(prompt, model, childDepth - 1);
|
|
73
61
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
62
|
+
|
|
63
|
+
const rem = opts.remainingBudget?.();
|
|
64
|
+
if (rem !== undefined) {
|
|
77
65
|
// Pre-spawn guard: refuse if the parent's budget or timeout is already exhausted
|
|
78
66
|
// (reference: _subcall checks remaining_budget/timeout before spawning).
|
|
79
67
|
const limitError = checkResourceLimits(rem);
|
|
80
|
-
if (limitError) return
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
68
|
+
if (limitError !== undefined) return limitError;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const emitter = opts.emitter();
|
|
72
|
+
const subId = emitter?.emitSubcallCreated({
|
|
73
|
+
kind: "rlm", parentId: opts.parentNodeId?.(), label: "rlm_query",
|
|
74
|
+
model: opts.modelLabel?.(model) ?? model ?? undefined,
|
|
75
|
+
detail: prompt.slice(0, 60),
|
|
76
|
+
depth: childDepth,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const res: RlmResult = await opts.run({
|
|
81
|
+
rootPrompt: "",
|
|
82
|
+
context: prompt,
|
|
91
83
|
depth: childDepth,
|
|
92
84
|
parentNodeId: subId,
|
|
93
|
-
modelOverride:
|
|
94
|
-
remainingBudgetUsd: rem
|
|
95
|
-
remainingTimeoutMs: rem
|
|
85
|
+
modelOverride: model ?? undefined,
|
|
86
|
+
remainingBudgetUsd: rem?.budgetUsd,
|
|
87
|
+
remainingTimeoutMs: rem?.timeoutMs,
|
|
96
88
|
});
|
|
97
89
|
opts.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
90
|
+
// The child emits live cost/token deltas on the shared emitter as it runs, so the node
|
|
91
|
+
// must NOT also receive a final aggregate — that would double-count.
|
|
92
|
+
if (emitter && subId !== undefined) {
|
|
93
|
+
emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: previewText(res.answer) });
|
|
94
|
+
}
|
|
95
|
+
return res.answer;
|
|
102
96
|
} catch (err) {
|
|
103
|
-
const msg =
|
|
104
|
-
if (subId)
|
|
105
|
-
return
|
|
97
|
+
const msg = errorMessage(err);
|
|
98
|
+
if (emitter && subId !== undefined) emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
|
|
99
|
+
return formatError(`child RLM failed - ${msg}`);
|
|
106
100
|
}
|
|
107
101
|
}
|
|
108
102
|
|
|
109
|
-
async function child(prompt: string, model: string | null, depth: number): Promise<string> {
|
|
110
|
-
const res = await childRun({
|
|
111
|
-
rootPrompt: "",
|
|
112
|
-
context: prompt,
|
|
113
|
-
depth: depth + 1,
|
|
114
|
-
model,
|
|
115
|
-
});
|
|
116
|
-
return res.answer;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
103
|
return {
|
|
120
|
-
rlmQuery: (prompt, model, depth) => child(prompt, model, depth),
|
|
121
|
-
rlmQueryBatched: (prompts, model, depth) =>
|
|
122
|
-
|
|
104
|
+
rlmQuery: (prompt, model, depth) => child(prompt, model, depth + 1),
|
|
105
|
+
rlmQueryBatched: (prompts, model, depth) =>
|
|
106
|
+
mapPool(prompts, opts.config().maxConcurrentSubcalls, (p) => child(p, model, depth + 1)),
|
|
123
107
|
};
|
|
124
108
|
}
|
|
@@ -11,15 +11,15 @@ export async function runRlmConfig(controller: RlmController, ctx: ExtensionCont
|
|
|
11
11
|
const models = ctx.modelRegistry.getAvailable();
|
|
12
12
|
|
|
13
13
|
const worker = await selectModel(ctx, "Worker model (sub-LLM / llm_query)", models, controller.workerModel, controller.config.subSampling.reasoning);
|
|
14
|
-
if (worker
|
|
15
|
-
controller.workerModel =
|
|
16
|
-
controller.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
if (worker !== undefined) {
|
|
15
|
+
controller.workerModel = worker?.model;
|
|
16
|
+
controller.setConfig(Object.freeze({
|
|
17
|
+
...controller.config,
|
|
18
|
+
subSampling: Object.freeze({ ...controller.config.subSampling, reasoning: worker?.thinkingLevel }),
|
|
19
|
+
}));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
await showConfigPanel(ctx, controller.config);
|
|
22
|
+
controller.setConfig(await showConfigPanel(ctx, controller.config));
|
|
23
23
|
|
|
24
24
|
if (worker === null) {
|
|
25
25
|
controller.savedWorkerRef = undefined;
|
|
@@ -29,7 +29,7 @@ export async function runRlmConfig(controller: RlmController, ctx: ExtensionCont
|
|
|
29
29
|
}
|
|
30
30
|
const persisted = await controller.persist();
|
|
31
31
|
if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
|
|
32
|
-
setRlmModeStatus(ctx.ui, controller);
|
|
32
|
+
setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
|
|
33
33
|
|
|
34
34
|
const w = controller.workerModel;
|
|
35
35
|
ctx.ui.notify(
|