@hicaru/pi-rlm 0.3.8 → 0.3.13
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 +3 -4
- package/package.json +1 -1
- package/src/bridge/handlers/completion.ts +5 -0
- package/src/bridge/handlers/emitting.ts +33 -23
- package/src/bridge/handlers/index.ts +1 -1
- package/src/bridge/handlers/llm-query.ts +23 -24
- package/src/bridge/handlers/rlm-query.ts +10 -32
- package/src/bridge/handlers/types.ts +8 -1
- package/src/bridge/model.ts +33 -15
- package/src/commands/pins.ts +51 -0
- package/src/commands/rlm-config.ts +4 -88
- package/src/commands/rlm-llm.ts +59 -0
- package/src/commands/rlm-rlm.ts +58 -0
- package/src/commands/rlm.ts +2 -2
- package/src/config/defaults.ts +14 -4
- package/src/config/settings.ts +26 -3
- package/src/core/budget.ts +1 -1
- package/src/core/compaction.ts +4 -0
- package/src/core/engine.ts +21 -4
- package/src/core/iteration.ts +12 -0
- package/src/core/ledger.ts +15 -123
- package/src/core/memory.ts +13 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/types.ts +14 -0
- package/src/index.ts +53 -4
- package/src/mode/rlm-mode.ts +11 -1
- package/src/prompts/glossary.ts +11 -3
- package/src/prompts/native.ts +1 -1
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/tool/repl-render.ts +4 -10
- package/src/tool/repl-tool.ts +30 -17
- package/src/tool/rlm-aggregator.ts +16 -3
- package/src/tool/rlm-details.ts +8 -0
- package/src/tool/rlm-events.ts +17 -1
- package/src/tool/rlm-tool.ts +25 -14
- package/src/tool/subcall-render.ts +14 -129
- package/src/tool/subcall-store.ts +11 -1
- package/src/ui/intro.ts +13 -4
- package/src/ui/modal/agent-modal.ts +104 -0
- package/src/ui/modal/modal-view.ts +132 -0
- package/src/ui/modal/timeline-store.ts +85 -0
- package/src/ui/model-picker/drilldown.ts +173 -0
- package/src/ui/model-picker/grouping.ts +81 -0
- package/src/ui/model-picker/levels.ts +63 -0
- package/src/ui/model-picker.ts +7 -197
- package/src/ui/panel/run-registry.ts +135 -0
- package/src/ui/panel/tree-panel.ts +46 -0
- package/src/ui/status.ts +26 -13
- package/src/ui/theme.ts +0 -4
- package/src/ui/tree/tree-model.ts +226 -0
- package/src/ui/tree/tree-rows.ts +74 -0
- package/src/ui/tree/tree-widget.ts +186 -0
- package/src/util/retry.ts +180 -0
- package/src/util/throttle.ts +90 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** `/rlm-rlm` — pin the root/worker model for rlm_query / rlm_batch child engines.
|
|
2
|
+
*
|
|
3
|
+
* Unpinned (default), child engines follow pi's active session model — exactly
|
|
4
|
+
* the pre-pin behavior, now an explicit picker row.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { modelRef } from "../config/settings.ts";
|
|
10
|
+
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
11
|
+
import { pickableModels, selectModel } from "../ui/model-picker.ts";
|
|
12
|
+
import { setRlmModeStatus } from "../ui/status.ts";
|
|
13
|
+
import { applyRlmSelection } from "./pins.ts";
|
|
14
|
+
|
|
15
|
+
function sessionScopedModels(
|
|
16
|
+
ctx: ExtensionContext,
|
|
17
|
+
): readonly { readonly model: Model<Api> }[] | undefined {
|
|
18
|
+
const scoped: unknown = Reflect.get(ctx, "scopedModels");
|
|
19
|
+
return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function runRlmRlm(controller: RlmController, ctx: ExtensionContext): Promise<void> {
|
|
23
|
+
try {
|
|
24
|
+
await ctx.modelRegistry.refresh();
|
|
25
|
+
} catch {
|
|
26
|
+
// Fail-soft: show the cached available snapshot rather than aborting config.
|
|
27
|
+
}
|
|
28
|
+
const models = pickableModels(ctx.modelRegistry, sessionScopedModels(ctx));
|
|
29
|
+
const rlm = await selectModel(
|
|
30
|
+
ctx,
|
|
31
|
+
"rlm",
|
|
32
|
+
models,
|
|
33
|
+
controller.rlmModel,
|
|
34
|
+
controller.config.rootSampling?.reasoning,
|
|
35
|
+
controller.savedRlmRef,
|
|
36
|
+
);
|
|
37
|
+
applyRlmSelection(controller, rlm);
|
|
38
|
+
const persisted = await controller.persist();
|
|
39
|
+
if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
|
|
40
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
41
|
+
|
|
42
|
+
const reasoning = controller.config.rootSampling?.reasoning;
|
|
43
|
+
ctx.ui.notify(
|
|
44
|
+
controller.rlmModel
|
|
45
|
+
? `RLM: rlm=${modelRef(controller.rlmModel) ?? "(none)"}${reasoning ? `/${reasoning}` : ""}`
|
|
46
|
+
: "RLM: rlm follows session model",
|
|
47
|
+
"info",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function registerRlmRlmCommand(pi: ExtensionAPI, controller: RlmController): void {
|
|
52
|
+
pi.registerCommand("rlm-rlm", {
|
|
53
|
+
description: "Pin the model used by rlm_query / rlm_batch child engines (default: session model).",
|
|
54
|
+
handler: async (_args, ctx) => {
|
|
55
|
+
await runRlmRlm(controller, ctx);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
package/src/commands/rlm.ts
CHANGED
|
@@ -9,7 +9,7 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
|
|
|
9
9
|
description: "Toggle persistent RLM mode (route plain prompts through the RLM engine).",
|
|
10
10
|
handler: async (_args, ctx) => {
|
|
11
11
|
const enabled = controller.toggle();
|
|
12
|
-
setRlmModeStatus(ctx
|
|
12
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
13
13
|
ctx.ui.notify(`RLM mode ${enabled ? "ON" : "OFF"}`, "info");
|
|
14
14
|
},
|
|
15
15
|
});
|
|
@@ -30,7 +30,7 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
|
|
|
30
30
|
description: "Toggle RLM mode (off also stops a running query)",
|
|
31
31
|
handler: async (ctx) => {
|
|
32
32
|
const enabled = controller.toggle();
|
|
33
|
-
setRlmModeStatus(ctx
|
|
33
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
34
34
|
ctx.ui.notify(`RLM mode ${enabled ? "ON" : "OFF"}`, "info");
|
|
35
35
|
},
|
|
36
36
|
});
|
package/src/config/defaults.ts
CHANGED
|
@@ -13,12 +13,22 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
|
|
|
13
13
|
execTimeoutS: 120,
|
|
14
14
|
requestTimeoutMs: 15 * 60_000,
|
|
15
15
|
// Session-wide, not per-batch: spawn() puts many requests on the wire at once, so this is
|
|
16
|
-
// the only thing bounding leaf fan-out.
|
|
17
|
-
|
|
16
|
+
// the only thing bounding leaf fan-out. Default 8 — a sane rate for per-account limits
|
|
17
|
+
// (raise to 16/32 via /rlm-config when the provider allows).
|
|
18
|
+
maxConcurrentSubcalls: 8,
|
|
18
19
|
// Children are bounded separately and lower: each is a Python subprocess holding its own copy
|
|
19
20
|
// of the context it inherited, where a leaf is one HTTP request. Worst case is
|
|
20
|
-
// (maxDepth - 1) × this many concurrent child engines.
|
|
21
|
-
maxConcurrentChildren:
|
|
21
|
+
// (maxDepth - 1) × this many concurrent child engines. Default 4.
|
|
22
|
+
maxConcurrentChildren: 4,
|
|
23
|
+
// v5.1 rate-limit resilience (util/retry.ts): 3 total attempts, 500ms→15s backoff,
|
|
24
|
+
// 2s→60s adaptive per-provider cooldown. All overridable in rlm.json.
|
|
25
|
+
retryMaxAttempts: 3,
|
|
26
|
+
// 429s park on the cooldown instead of dying — up to 8 windows (2s→4s→…≤60s ≈ 4 min).
|
|
27
|
+
rateLimitMaxAttempts: 8,
|
|
28
|
+
retryBaseDelayMs: 500,
|
|
29
|
+
retryMaxDelayMs: 15_000,
|
|
30
|
+
throttleBaseMs: 2_000,
|
|
31
|
+
throttleMaxMs: 60_000,
|
|
22
32
|
maxPromptChars: 400_000,
|
|
23
33
|
maxErrors: 5,
|
|
24
34
|
orchestrator: true,
|
package/src/config/settings.ts
CHANGED
|
@@ -12,6 +12,9 @@ export interface PersistedSettings {
|
|
|
12
12
|
/** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)".
|
|
13
13
|
* `null` = explicit "cheapest" clear (omit key on disk). */
|
|
14
14
|
readonly llm?: string | null;
|
|
15
|
+
/** "provider/id" of the pinned rlm root/worker model, or undefined for "follow session".
|
|
16
|
+
* `null` = explicit "follow session model" clear (omit key on disk). */
|
|
17
|
+
readonly rlm?: string | null;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
type MutablePartialRlmConfig = { -readonly [K in keyof RlmConfig]?: RlmConfig[K] };
|
|
@@ -65,6 +68,19 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
65
68
|
if (maxConcurrentSubcalls !== undefined) out.maxConcurrentSubcalls = maxConcurrentSubcalls;
|
|
66
69
|
const maxConcurrentChildren = validateNumber(r.maxConcurrentChildren, 1);
|
|
67
70
|
if (maxConcurrentChildren !== undefined) out.maxConcurrentChildren = maxConcurrentChildren;
|
|
71
|
+
// v5.1 rate-limit resilience knobs
|
|
72
|
+
const retryMaxAttempts = validateNumber(r.retryMaxAttempts, 1);
|
|
73
|
+
if (retryMaxAttempts !== undefined) out.retryMaxAttempts = retryMaxAttempts;
|
|
74
|
+
const rateLimitMaxAttempts = validateNumber(r.rateLimitMaxAttempts, 1);
|
|
75
|
+
if (rateLimitMaxAttempts !== undefined) out.rateLimitMaxAttempts = rateLimitMaxAttempts;
|
|
76
|
+
const retryBaseDelayMs = validateNumber(r.retryBaseDelayMs, 0);
|
|
77
|
+
if (retryBaseDelayMs !== undefined) out.retryBaseDelayMs = retryBaseDelayMs;
|
|
78
|
+
const retryMaxDelayMs = validateNumber(r.retryMaxDelayMs, 100);
|
|
79
|
+
if (retryMaxDelayMs !== undefined) out.retryMaxDelayMs = retryMaxDelayMs;
|
|
80
|
+
const throttleBaseMs = validateNumber(r.throttleBaseMs, 0);
|
|
81
|
+
if (throttleBaseMs !== undefined) out.throttleBaseMs = throttleBaseMs;
|
|
82
|
+
const throttleMaxMs = validateNumber(r.throttleMaxMs, 100);
|
|
83
|
+
if (throttleMaxMs !== undefined) out.throttleMaxMs = throttleMaxMs;
|
|
68
84
|
const maxPromptChars = validateNumber(r.maxPromptChars, 1000);
|
|
69
85
|
if (maxPromptChars !== undefined) out.maxPromptChars = maxPromptChars;
|
|
70
86
|
const maxTimeoutMs = validateNumber(r.maxTimeoutMs, 1000);
|
|
@@ -167,6 +183,7 @@ export async function loadSettings(): Promise<PersistedSettings> {
|
|
|
167
183
|
config: validateConfig(r.config),
|
|
168
184
|
// `worker` is the pre-rename key — still read so an existing pin survives the upgrade.
|
|
169
185
|
llm: validateString(r.llm) ?? validateString(r.worker),
|
|
186
|
+
rlm: validateString(r.rlm),
|
|
170
187
|
};
|
|
171
188
|
} catch {
|
|
172
189
|
return { config: {} };
|
|
@@ -178,13 +195,19 @@ export async function saveSettings(s: PersistedSettings): Promise<boolean> {
|
|
|
178
195
|
const p = settingsPath();
|
|
179
196
|
await mkdir(dirname(p), { recursive: true });
|
|
180
197
|
const body: Record<string, unknown> = { config: s.config };
|
|
198
|
+
const mergeDisk = s.llm === undefined || s.rlm === undefined;
|
|
199
|
+
const existing = mergeDisk ? await loadSettings() : undefined;
|
|
181
200
|
if (s.llm !== undefined) {
|
|
182
201
|
// Explicit: string → write pin, null → omit key (cheapest).
|
|
183
202
|
if (s.llm !== null) body.llm = s.llm;
|
|
184
|
-
} else {
|
|
203
|
+
} else if (existing?.llm) {
|
|
185
204
|
// Merge: preserve existing disk pin so config-only saves never strip it.
|
|
186
|
-
|
|
187
|
-
|
|
205
|
+
body.llm = existing.llm;
|
|
206
|
+
}
|
|
207
|
+
if (s.rlm !== undefined) {
|
|
208
|
+
if (s.rlm !== null) body.rlm = s.rlm;
|
|
209
|
+
} else if (existing?.rlm) {
|
|
210
|
+
body.rlm = existing.rlm;
|
|
188
211
|
}
|
|
189
212
|
await writeFile(p, `${JSON.stringify(body, null, 2)}\n`);
|
|
190
213
|
return true;
|
package/src/core/budget.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Token budget cascade (port of
|
|
2
|
+
* Token budget cascade (port of the v4/v5 `budget.py` engine).
|
|
3
3
|
*
|
|
4
4
|
* The budget is the PRIMARY run-length control: cap = budgetShare × model context window,
|
|
5
5
|
* one soft wrap-up turn at `softFrac` of the cap, and at the hard cap a deterministic
|
package/src/core/compaction.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
10
10
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
12
|
+
import type { RetryPolicy } from "../util/retry.ts";
|
|
12
13
|
import { estimateMessageTokens } from "../text/tokens.ts";
|
|
13
14
|
|
|
14
15
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
@@ -24,6 +25,8 @@ export interface CompactionDeps {
|
|
|
24
25
|
readonly contextWindow?: number;
|
|
25
26
|
readonly thresholdPct?: number;
|
|
26
27
|
readonly signal?: AbortSignal;
|
|
28
|
+
/** v5.1 retry policy for modelComplete; defaults apply when omitted. */
|
|
29
|
+
readonly retry?: RetryPolicy;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/** True if the history is at/over the compaction threshold. */
|
|
@@ -93,6 +96,7 @@ export async function compactHistory(
|
|
|
93
96
|
model: deps.model,
|
|
94
97
|
registry: deps.registry,
|
|
95
98
|
signal: deps.signal,
|
|
99
|
+
retry: deps.retry,
|
|
96
100
|
});
|
|
97
101
|
onUsage?.(usage);
|
|
98
102
|
const system = history.find((m) => m.role === "system");
|
package/src/core/engine.ts
CHANGED
|
@@ -22,12 +22,14 @@ import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
|
22
22
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
23
23
|
import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
|
|
24
24
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
25
|
+
import type { SubcallPhase } from "../tool/rlm-details.ts";
|
|
25
26
|
import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox.ts";
|
|
26
27
|
import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
|
|
27
28
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
28
29
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
29
30
|
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
30
31
|
import { compactHistory, elideOldToolPayloads, shouldCompact } from "./compaction.ts";
|
|
32
|
+
import { retryPolicy } from "../util/retry.ts";
|
|
31
33
|
import { appendUserMessage } from "./history.ts";
|
|
32
34
|
import { runTurn } from "./iteration.ts";
|
|
33
35
|
import { type Limits, LimitError, LimitGuard } from "./limits.ts";
|
|
@@ -119,6 +121,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
119
121
|
},
|
|
120
122
|
},
|
|
121
123
|
};
|
|
124
|
+
// Live activity phase for the tree UI: child engines report on their own subcall
|
|
125
|
+
// node; the root engine has no node, so it reports via the root-phase channel.
|
|
126
|
+
const reportPhase = (phase: SubcallPhase): void => {
|
|
127
|
+
if (selfReportId !== undefined) emitter.emitSubcallUpdated({ id: selfReportId, phase });
|
|
128
|
+
else emitter.emitRootPhase(phase);
|
|
129
|
+
};
|
|
122
130
|
// Detached work must not outlive the sandbox we dispose in `finally`: track it so the
|
|
123
131
|
// run can settle or abort it first (a child engine left running would keep spending).
|
|
124
132
|
let detachedInFlight = 0;
|
|
@@ -131,7 +139,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
131
139
|
if (deps.config.enableLedger) runLedger.beginRun(input.rootPrompt);
|
|
132
140
|
|
|
133
141
|
// v5 durable memory: read-only root replay — an identical prompt over an identical
|
|
134
|
-
// context answers for zero API calls (measured 10,051 → 0 tok in
|
|
142
|
+
// context answers for zero API calls (measured 10,051 → 0 tok in bake-off runs).
|
|
135
143
|
const rootMemory =
|
|
136
144
|
deps.memory !== undefined && deps.config.enableMemory ? deps.memory : undefined;
|
|
137
145
|
const modelRefStr = `${model.provider}/${model.id}`;
|
|
@@ -276,9 +284,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
276
284
|
}).handlers
|
|
277
285
|
: {};
|
|
278
286
|
|
|
287
|
+
// v5 doctrine: one condition feeds BOTH the python surface and the memory scope —
|
|
288
|
+
// delegation children keep llm/memory-read/ledger, never repo retrieval or memory.add.
|
|
289
|
+
const surface = input.depth > 0 && deps.config.childSurface === "delegation" ? "child" : "root";
|
|
279
290
|
sandbox = await PythonSandbox.spawn({
|
|
280
291
|
depth: input.depth,
|
|
281
|
-
surface
|
|
292
|
+
surface,
|
|
282
293
|
execTimeoutS: deps.config.execTimeoutS,
|
|
283
294
|
requestTimeoutMs: deps.config.requestTimeoutMs,
|
|
284
295
|
python: deps.config.python,
|
|
@@ -290,7 +301,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
290
301
|
...subcalls,
|
|
291
302
|
...contextHandlers,
|
|
292
303
|
ledgerClaims: () => Promise.resolve(runLedger.listClaims()),
|
|
293
|
-
memoryOp: (op, args) =>
|
|
304
|
+
memoryOp: (op, args) =>
|
|
305
|
+
Promise.resolve(
|
|
306
|
+
rootMemory === undefined ? "memory off" : rootMemory.serviceOp(op, args, surface === "child" ? "child" : "root"),
|
|
307
|
+
),
|
|
294
308
|
},
|
|
295
309
|
});
|
|
296
310
|
|
|
@@ -316,6 +330,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
316
330
|
registry: deps.registry,
|
|
317
331
|
contextWindow: model.contextWindow,
|
|
318
332
|
thresholdPct: deps.config.compactionThresholdPct,
|
|
333
|
+
retry: retryPolicy(deps.config),
|
|
319
334
|
signal: deps.signal,
|
|
320
335
|
};
|
|
321
336
|
if (shouldCompact(history, compactionDeps)) {
|
|
@@ -328,7 +343,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
328
343
|
pendingReplOutputs = undefined;
|
|
329
344
|
}
|
|
330
345
|
|
|
331
|
-
// Soft runtime nudge (
|
|
346
|
+
// Soft runtime nudge (engine parity): remind the model to await pending host tasks.
|
|
332
347
|
const pendingIds = taskRegistry.awaitDeps.unawaitedIds();
|
|
333
348
|
if (pendingIds.length > 0) {
|
|
334
349
|
appendUserMessage(
|
|
@@ -359,8 +374,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
359
374
|
model: model,
|
|
360
375
|
registry: deps.registry,
|
|
361
376
|
sampling: rootSampling,
|
|
377
|
+
retry: deps.complete === undefined ? retryPolicy(deps.config) : undefined,
|
|
362
378
|
signal: deps.signal,
|
|
363
379
|
complete: deps.complete,
|
|
380
|
+
onPhase: reportPhase,
|
|
364
381
|
});
|
|
365
382
|
const allBlocks = turn.blocks.length > 0
|
|
366
383
|
? turn.blocks.map((b) => previewText(b, 400)).join("\n")
|
package/src/core/iteration.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
8
8
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { type ChatMsg, type CompleteOptions, type CompleteResult, modelComplete } from "../bridge/model.ts";
|
|
10
|
+
import type { RetryPolicy } from "../util/retry.ts";
|
|
10
11
|
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
11
12
|
import type { PythonSandbox } from "../sandbox/sandbox.ts";
|
|
12
13
|
import { findReplBlocks } from "../text/parsing.ts";
|
|
14
|
+
import type { SubcallPhase } from "../tool/rlm-details.ts";
|
|
13
15
|
import type { Sampling } from "./types.ts";
|
|
14
16
|
|
|
15
17
|
export interface Turn {
|
|
@@ -30,20 +32,30 @@ export interface TurnDeps {
|
|
|
30
32
|
readonly signal?: AbortSignal;
|
|
31
33
|
/** Test-only override for model completion (scripted responses). */
|
|
32
34
|
readonly complete?: CompleteFn;
|
|
35
|
+
/** v5.1 retry policy for modelComplete (rate-limit resilience); defaults apply when omitted. */
|
|
36
|
+
readonly retry?: RetryPolicy;
|
|
37
|
+
/** Live activity reporting for the tree UI (thinking → repl/texting per turn). */
|
|
38
|
+
readonly onPhase?: (phase: SubcallPhase) => void;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export async function runTurn(history: readonly ChatMsg[], sandbox: PythonSandbox, deps: TurnDeps): Promise<Turn> {
|
|
36
42
|
const complete = deps.complete ?? modelComplete;
|
|
43
|
+
deps.onPhase?.("thinking");
|
|
37
44
|
const { text, usage } = await complete(history, {
|
|
38
45
|
model: deps.model,
|
|
39
46
|
registry: deps.registry,
|
|
40
47
|
maxTokens: deps.sampling?.maxTokens,
|
|
41
48
|
temperature: deps.sampling?.temperature,
|
|
42
49
|
reasoning: deps.sampling?.reasoning,
|
|
50
|
+
retry: deps.retry,
|
|
51
|
+
// Rate-limit parking is a visible phase too — the root/child spinner says "queued".
|
|
52
|
+
onThrottlePark: deps.onPhase ? () => deps.onPhase?.("queued") : undefined,
|
|
53
|
+
onThrottleRelease: deps.onPhase ? () => deps.onPhase?.("thinking") : undefined,
|
|
43
54
|
signal: deps.signal,
|
|
44
55
|
});
|
|
45
56
|
|
|
46
57
|
const blocks = findReplBlocks(text);
|
|
58
|
+
deps.onPhase?.(blocks.length > 0 ? "repl" : "texting");
|
|
47
59
|
const results = new Array<ReplResult>(blocks.length);
|
|
48
60
|
let executed = 0;
|
|
49
61
|
for (let i = 0; i < blocks.length; i++) {
|
package/src/core/ledger.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TaskLedger — the session blackboard (port of
|
|
2
|
+
* TaskLedger — the session blackboard (port of the v5 `ledger.py` engine).
|
|
3
3
|
*
|
|
4
4
|
* One instance per root run (engine) or per session (native repl tool), threaded down to every
|
|
5
5
|
* child through `SubcallHandlerDeps.ledger` / `RlmInput.ledger` — the same seam as
|
|
@@ -139,111 +139,10 @@ export function taskKey(
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
export const ECHO_STUB: string = Object.freeze(
|
|
142
|
-
"[ledger
|
|
143
|
-
"
|
|
142
|
+
"[ledger: ancestor echo rejected. You are already doing this task. " +
|
|
143
|
+
"Use context / answers / the files already in scope. Do not rlm_query the parent task.]",
|
|
144
144
|
);
|
|
145
145
|
|
|
146
|
-
const RLM_CALL_OPEN = /\brlm_(?:query|batch)\s*\(/g;
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Native `repl()` cells are Python, not a task (audit R1). Pull quoted
|
|
150
|
-
* `rlm_query` / `rlm_batch` arguments so `beginRun` has a task-shaped ancestor
|
|
151
|
-
* instead of `print` / `await_task` tokens. Falls back to the raw cell when no
|
|
152
|
-
* such call is present. `paths=` keyword args are not tasks.
|
|
153
|
-
*/
|
|
154
|
-
export function nativeRunAncestors(code: string): readonly string[] {
|
|
155
|
-
const found = extractRlmTaskPrompts(code);
|
|
156
|
-
return Object.freeze(found.length > 0 ? found : [code]);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function extractRlmTaskPrompts(code: string): readonly string[] {
|
|
160
|
-
const out: string[] = [];
|
|
161
|
-
RLM_CALL_OPEN.lastIndex = 0;
|
|
162
|
-
for (const m of code.matchAll(RLM_CALL_OPEN)) {
|
|
163
|
-
const start = (m.index ?? 0) + m[0].length;
|
|
164
|
-
const body = sliceCallBody(code, start);
|
|
165
|
-
const pathSplit = body.split(/\bpaths\s*=/);
|
|
166
|
-
const taskPart = pathSplit[0] ?? body;
|
|
167
|
-
const strings = quotedStrings(taskPart);
|
|
168
|
-
for (let i = 0; i < strings.length; i++) {
|
|
169
|
-
const s = strings[i];
|
|
170
|
-
if (s !== undefined && s.trim() !== "") out.push(s);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return out;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function sliceCallBody(src: string, start: number): string {
|
|
177
|
-
let depth = 1;
|
|
178
|
-
let i = start;
|
|
179
|
-
while (i < src.length && depth > 0) {
|
|
180
|
-
const c = src[i];
|
|
181
|
-
if (c === "'" || c === '"') {
|
|
182
|
-
i = skipPyString(src, i);
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
if (c === "#") {
|
|
186
|
-
const nl = src.indexOf("\n", i);
|
|
187
|
-
i = nl === -1 ? src.length : nl + 1;
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
if (c === "(") depth++;
|
|
191
|
-
else if (c === ")") depth--;
|
|
192
|
-
i++;
|
|
193
|
-
}
|
|
194
|
-
return src.slice(start, depth === 0 ? i - 1 : i);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function quotedStrings(src: string): readonly string[] {
|
|
198
|
-
const out: string[] = [];
|
|
199
|
-
let i = 0;
|
|
200
|
-
while (i < src.length) {
|
|
201
|
-
const c = src[i];
|
|
202
|
-
if (c === "'" || c === '"') {
|
|
203
|
-
const parsed = readPyString(src, i);
|
|
204
|
-
if (parsed.keep) out.push(parsed.value);
|
|
205
|
-
i = parsed.end;
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
i++;
|
|
209
|
-
}
|
|
210
|
-
return out;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function skipPyString(src: string, quoteAt: number): number {
|
|
214
|
-
return readPyString(src, quoteAt).end;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function readPyString(
|
|
218
|
-
src: string,
|
|
219
|
-
quoteAt: number,
|
|
220
|
-
): { readonly value: string; readonly end: number; readonly keep: boolean } {
|
|
221
|
-
const quote = src[quoteAt] ?? '"';
|
|
222
|
-
const prefix = quoteAt > 0 ? src[quoteAt - 1] : "";
|
|
223
|
-
const keep = prefix !== "f" && prefix !== "F";
|
|
224
|
-
const triple = src.startsWith(quote + quote + quote, quoteAt);
|
|
225
|
-
const delimLen = triple ? 3 : 1;
|
|
226
|
-
const from = quoteAt + delimLen;
|
|
227
|
-
if (triple) {
|
|
228
|
-
const close = src.indexOf(quote + quote + quote, from);
|
|
229
|
-
if (close === -1) return { value: src.slice(from), end: src.length, keep };
|
|
230
|
-
return { value: src.slice(from, close), end: close + 3, keep };
|
|
231
|
-
}
|
|
232
|
-
const parts: string[] = [];
|
|
233
|
-
let j = from;
|
|
234
|
-
while (j < src.length) {
|
|
235
|
-
const ch = src[j];
|
|
236
|
-
if (ch === "\\") {
|
|
237
|
-
parts.push(src[j + 1] ?? "");
|
|
238
|
-
j += 2;
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
if (ch === quote) return { value: parts.join(""), end: j + 1, keep };
|
|
242
|
-
parts.push(ch ?? "");
|
|
243
|
-
j++;
|
|
244
|
-
}
|
|
245
|
-
return { value: parts.join(""), end: src.length, keep };
|
|
246
|
-
}
|
|
247
146
|
|
|
248
147
|
export class TaskLedger {
|
|
249
148
|
private readonly claims = new Map<string, Claim>();
|
|
@@ -252,7 +151,11 @@ export class TaskLedger {
|
|
|
252
151
|
private readonly hitCounts = { exact: 0, echo: 0, near: 0 };
|
|
253
152
|
private rlmRuns = 0;
|
|
254
153
|
|
|
255
|
-
/** Engine marks the active run's root prompt — the ancestor chain for echo detection.
|
|
154
|
+
/** Engine marks the active run's root prompt — the ancestor chain for echo detection.
|
|
155
|
+
* v5 `begin_run` parity: the ONLY producer. `endRun` pops in the engine's finally, so a
|
|
156
|
+
* stack entry exists exactly while that engine is RUNNING — v5's `status in (pending,
|
|
157
|
+
* running)` filter is structural here. Native `repl()` cells never push ancestors: their
|
|
158
|
+
* spawns claim against an empty stack, so an originator can never echo against itself. */
|
|
256
159
|
beginRun(rootPrompt: string): void {
|
|
257
160
|
this.stack.push(normalizePrompt(rootPrompt));
|
|
258
161
|
}
|
|
@@ -261,20 +164,6 @@ export class TaskLedger {
|
|
|
261
164
|
this.stack.pop();
|
|
262
165
|
}
|
|
263
166
|
|
|
264
|
-
/** Native `repl()` path (audit R1): push task-shaped ancestors extracted from the cell. */
|
|
265
|
-
beginNativeCell(code: string): number {
|
|
266
|
-
const ancestors = nativeRunAncestors(code);
|
|
267
|
-
for (let i = 0; i < ancestors.length; i++) {
|
|
268
|
-
const a = ancestors[i];
|
|
269
|
-
if (a !== undefined) this.beginRun(a);
|
|
270
|
-
}
|
|
271
|
-
return ancestors.length;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
endNativeCell(n: number): void {
|
|
275
|
-
for (let i = 0; i < n; i++) this.endRun();
|
|
276
|
-
}
|
|
277
|
-
|
|
278
167
|
/** A child prompt echoing any ancestor (exact or ≥ 0.8 Jaccard) is rejected as a stub. */
|
|
279
168
|
detectEcho(prompt: string): boolean {
|
|
280
169
|
const np = normalizePrompt(prompt);
|
|
@@ -410,11 +299,13 @@ export class TaskLedger {
|
|
|
410
299
|
return Object.freeze({ ...this.hitCounts });
|
|
411
300
|
}
|
|
412
301
|
|
|
413
|
-
/** Compact table for the sandbox's `list_claims()` REPL call.
|
|
302
|
+
/** Compact table for the sandbox's `list_claims()` REPL call. Echo hits are appended —
|
|
303
|
+
* a suppressed spawn must be visible from inside the session (audit BUG-1). */
|
|
414
304
|
listClaims(): string {
|
|
415
|
-
|
|
305
|
+
const echoNote = this.hitCounts.echo > 0 ? ` (echo_rejected=${this.hitCounts.echo})` : "";
|
|
306
|
+
if (this.claims.size === 0) return `ledger: no claims${echoNote}`;
|
|
416
307
|
const lines: string[] = new Array<string>(this.claims.size + 1);
|
|
417
|
-
lines[0] =
|
|
308
|
+
lines[0] = `ledger claims:${echoNote}`;
|
|
418
309
|
let n = 1;
|
|
419
310
|
for (const c of this.claims.values()) {
|
|
420
311
|
lines[n++] = ` ${c.key.slice(0, 8)} ${c.kind} ${c.status} depth=${c.depth} paths=${pathSig(c.paths) || "-"} '${c.prompt.slice(0, PROMPT_PREVIEW)}'`;
|
|
@@ -435,7 +326,8 @@ export class TaskLedger {
|
|
|
435
326
|
if (inflight.length === 0 && done.length === 0 && stackN <= 1) return "";
|
|
436
327
|
const lines: string[] = [
|
|
437
328
|
"[ledger]",
|
|
438
|
-
` depth_stack=${stackN} inflight=${inflight.length} done=${done.length}
|
|
329
|
+
` depth_stack=${stackN} inflight=${inflight.length} done=${done.length}` +
|
|
330
|
+
(this.hitCounts.echo > 0 ? ` echo_rejected=${this.hitCounts.echo}` : ""),
|
|
439
331
|
" rlm_query only for a disjoint goal. ancestor echo is rejected.",
|
|
440
332
|
];
|
|
441
333
|
if (inflight.length > 0) {
|
package/src/core/memory.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Durable memory (port of
|
|
2
|
+
* Durable memory (port of the v5 `memory/store.py` engine).
|
|
3
3
|
*
|
|
4
4
|
* L1 episodes: content-addressed replay — a recorded child/root answer replays for ZERO
|
|
5
5
|
* API calls while every file it touched still hashes to the recorded sha256.
|
|
@@ -15,6 +15,7 @@ import { createHash } from "node:crypto";
|
|
|
15
15
|
import { closeSync, openSync, readSync } from "node:fs";
|
|
16
16
|
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { dirname, join, resolve, sep } from "node:path";
|
|
18
|
+
import { formatError } from "../util/errors.ts";
|
|
18
19
|
|
|
19
20
|
const TOK = /[a-z0-9]{2,}/g;
|
|
20
21
|
const EPISODE_CAP = 4_000;
|
|
@@ -123,6 +124,10 @@ export function rootContextPaths(context: unknown, max: number): readonly string
|
|
|
123
124
|
return Object.freeze(out);
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/** Who is calling serviceOp — delegation children read durable notes but never write them
|
|
128
|
+
* (their findings persist via recordEpisode in the rlm_query handler, one per run). */
|
|
129
|
+
export type MemoryScope = "root" | "child";
|
|
130
|
+
|
|
126
131
|
export class MemoryStore {
|
|
127
132
|
readonly enabled: boolean;
|
|
128
133
|
private dir: string | undefined;
|
|
@@ -462,9 +467,16 @@ export class MemoryStore {
|
|
|
462
467
|
serviceOp(
|
|
463
468
|
op: "query" | "add" | "stats",
|
|
464
469
|
args: { readonly query?: string; readonly k?: number; readonly content?: string; readonly paths?: readonly string[]; readonly tags?: readonly string[] },
|
|
470
|
+
scope: MemoryScope = "root",
|
|
465
471
|
): string {
|
|
466
472
|
if (!this.enabled) return "memory disabled";
|
|
467
473
|
if (op === "stats") return JSON.stringify(this.stats());
|
|
474
|
+
if (op === "add" && scope === "child") {
|
|
475
|
+
return formatError(
|
|
476
|
+
"memory.add is root-only — query durable notes with memory.query; " +
|
|
477
|
+
"your final answer is recorded as an episode automatically",
|
|
478
|
+
);
|
|
479
|
+
}
|
|
468
480
|
if (op === "add") {
|
|
469
481
|
const n = this.addNote({ content: args.content ?? "", paths: args.paths ?? [], tags: args.tags ?? [] });
|
|
470
482
|
return n === undefined ? "add skipped (empty content)" : `ok note ${n.id}`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Model context-window registry (port of
|
|
2
|
+
* Model context-window registry (port of the v4/v5 `models.py` engine).
|
|
3
3
|
*
|
|
4
4
|
* The plugin already knows context windows from model metadata (`Model.contextWindow`); this
|
|
5
5
|
* registry is the offline fallback for models whose metadata carries none: a conservative
|
package/src/core/types.ts
CHANGED
|
@@ -25,6 +25,20 @@ export interface RlmConfig {
|
|
|
25
25
|
/** Concurrent recursive child engines admitted per depth. Lower than maxConcurrentSubcalls:
|
|
26
26
|
* each child is a Python subprocess holding its own copy of the inherited context. */
|
|
27
27
|
readonly maxConcurrentChildren: number;
|
|
28
|
+
/** v5.1 rate-limit resilience (see util/retry.ts): transient 429/5xx are retried with
|
|
29
|
+
* backoff, and rate limits additionally cool a shared per-provider throttle.
|
|
30
|
+
* retryMaxAttempts counts TOTAL attempts per call (1 = never retry).
|
|
31
|
+
* rateLimitMaxAttempts is the SEPARATE budget a 429 may burn while parking on the
|
|
32
|
+
* cooldown — generous, because "come back later" is a queue, not a failure. */
|
|
33
|
+
readonly retryMaxAttempts?: number;
|
|
34
|
+
readonly rateLimitMaxAttempts?: number;
|
|
35
|
+
readonly retryBaseDelayMs?: number;
|
|
36
|
+
/** Cap for any single retry delay, including a parsed `retry-after`. */
|
|
37
|
+
readonly retryMaxDelayMs?: number;
|
|
38
|
+
/** First cooldown when a provider 429s without timing; doubles per consecutive strike. */
|
|
39
|
+
readonly throttleBaseMs?: number;
|
|
40
|
+
/** Ceiling for the adaptive per-provider cooldown. */
|
|
41
|
+
readonly throttleMaxMs?: number;
|
|
28
42
|
/** Reject sub-LLM prompts larger than this many chars. */
|
|
29
43
|
readonly maxPromptChars: number;
|
|
30
44
|
/** Max wall-clock ms across the whole tree before the engine stops (undefined = no cap). */
|