@hicaru/pi-rlm 0.2.0 → 0.2.2
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 +12 -35
- package/README.ru.md +18 -23
- package/README.zh-CN.md +17 -28
- package/package.json +1 -1
- package/src/bridge/library.ts +61 -26
- package/src/bridge/subcall-handlers.ts +382 -0
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +7 -15
- package/src/config/settings.ts +8 -32
- package/src/context/library-context.ts +90 -17
- package/src/core/engine.ts +115 -360
- package/src/core/history.ts +1 -1
- package/src/core/limits.ts +5 -12
- package/src/core/resource-limits.ts +0 -2
- package/src/core/types.ts +3 -36
- package/src/index.ts +49 -10
- package/src/mode/llm-model.ts +54 -0
- package/src/mode/rlm-mode.ts +26 -57
- package/src/prompts/glossary.ts +287 -0
- package/src/prompts/native.ts +127 -0
- package/src/prompts/system.ts +14 -386
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +145 -0
- package/src/sandbox/protocol.ts +14 -69
- package/src/sandbox/py/guards.py +150 -0
- package/src/sandbox/py/retrieval.py +265 -0
- package/src/sandbox/py/tasks.py +116 -0
- package/src/sandbox/py/worker.py +836 -0
- package/src/sandbox/sandbox-manager.ts +33 -6
- package/src/sandbox/sandbox.ts +153 -182
- package/src/text/tokens.ts +29 -3
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +4 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +178 -216
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +10 -16
- package/src/tool/rlm-tool.ts +1 -12
- package/src/tool/subcall-render.ts +15 -3
- package/src/tool/subcall-store.ts +57 -1
- package/src/ui/config-panel.ts +4 -16
- package/src/ui/intro.ts +1 -2
- package/src/ui/model-picker.ts +34 -10
- package/src/ui/status.ts +3 -7
- package/src/util/concurrency.ts +91 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/fallback-todo.ts +0 -137
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/llm-query.ts +0 -156
- package/src/bridge/pi-interactive.ts +0 -41
- package/src/bridge/rlm-query.ts +0 -108
- package/src/core/artifacts.ts +0 -89
- package/src/core/critique.ts +0 -92
- package/src/core/gates.ts +0 -301
- package/src/core/pipeline-handlers.ts +0 -319
- package/src/core/pipeline.ts +0 -268
- package/src/prompts/phases.ts +0 -104
- package/src/sandbox/worker.py +0 -1078
- package/src/state/index.ts +0 -24
- package/src/state/internal.ts +0 -46
- package/src/state/paths.ts +0 -44
- package/src/state/reads.ts +0 -133
- package/src/state/resume.ts +0 -173
- package/src/state/rows.ts +0 -123
- package/src/state/writes.ts +0 -58
package/src/core/limits.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* LimitGuard — wall-clock, token,
|
|
2
|
+
* LimitGuard — wall-clock, token, and consecutive-error caps for a headless RLM run
|
|
3
3
|
* (ported from rlm/core/rlm.py `_check_timeout` / `_check_iteration_limits`). Any breach throws
|
|
4
4
|
* a LimitError; the engine catches it and returns the best partial answer it has.
|
|
5
|
+
*
|
|
6
|
+
* Cost is tracked for reporting only — there is no USD spend ceiling.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
@@ -9,14 +11,12 @@ import type { Usage } from "@earendil-works/pi-ai";
|
|
|
9
11
|
export interface Limits {
|
|
10
12
|
readonly maxTimeoutMs?: number;
|
|
11
13
|
readonly maxTokens?: number;
|
|
12
|
-
readonly maxBudgetUsd?: number;
|
|
13
14
|
readonly maxErrors?: number;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/** Pick the limit caps out of a config (`RlmConfig` satisfies this structurally). */
|
|
17
18
|
export function limitsFromConfig(config: Limits): Limits {
|
|
18
19
|
return {
|
|
19
|
-
maxBudgetUsd: config.maxBudgetUsd,
|
|
20
20
|
maxTimeoutMs: config.maxTimeoutMs,
|
|
21
21
|
maxTokens: config.maxTokens,
|
|
22
22
|
maxErrors: config.maxErrors,
|
|
@@ -33,7 +33,7 @@ export interface UsageSnapshot {
|
|
|
33
33
|
|
|
34
34
|
export class LimitError extends Error {
|
|
35
35
|
constructor(
|
|
36
|
-
public readonly kind: "timeout" | "tokens" | "
|
|
36
|
+
public readonly kind: "timeout" | "tokens" | "errors",
|
|
37
37
|
message: string,
|
|
38
38
|
) {
|
|
39
39
|
super(message);
|
|
@@ -77,16 +77,13 @@ export class LimitGuard {
|
|
|
77
77
|
/** Call after each turn with whether the turn's REPL produced an error. */
|
|
78
78
|
observe(hadError: boolean): void {
|
|
79
79
|
this.consecutiveErrors = hadError ? this.consecutiveErrors + 1 : 0;
|
|
80
|
-
const { maxErrors, maxTokens
|
|
80
|
+
const { maxErrors, maxTokens } = this.limits;
|
|
81
81
|
if (maxErrors && this.consecutiveErrors >= maxErrors) {
|
|
82
82
|
throw new LimitError("errors", `${this.consecutiveErrors} consecutive errors (limit ${maxErrors})`);
|
|
83
83
|
}
|
|
84
84
|
if (maxTokens && this.inputTokens + this.outputTokens > maxTokens) {
|
|
85
85
|
throw new LimitError("tokens", `${this.inputTokens + this.outputTokens} tokens (limit ${maxTokens})`);
|
|
86
86
|
}
|
|
87
|
-
if (maxBudgetUsd && this.costUsd > maxBudgetUsd) {
|
|
88
|
-
throw new LimitError("budget", `$${this.costUsd.toFixed(4)} spent (limit $${maxBudgetUsd})`);
|
|
89
|
-
}
|
|
90
87
|
}
|
|
91
88
|
|
|
92
89
|
usage(): UsageSnapshot {
|
|
@@ -98,10 +95,6 @@ export class LimitGuard {
|
|
|
98
95
|
};
|
|
99
96
|
}
|
|
100
97
|
|
|
101
|
-
remainingBudgetUsd(): number | undefined {
|
|
102
|
-
return this.limits.maxBudgetUsd === undefined ? undefined : this.limits.maxBudgetUsd - this.costUsd;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
98
|
remainingTimeoutMs(): number | undefined {
|
|
106
99
|
return this.limits.maxTimeoutMs === undefined ? undefined : this.limits.maxTimeoutMs - (Date.now() - this.start);
|
|
107
100
|
}
|
|
@@ -3,12 +3,10 @@
|
|
|
3
3
|
import { formatError } from "../util/errors.ts";
|
|
4
4
|
|
|
5
5
|
export interface RemainingResources {
|
|
6
|
-
readonly budgetUsd?: number;
|
|
7
6
|
readonly timeoutMs?: number;
|
|
8
7
|
}
|
|
9
8
|
|
|
10
9
|
export function checkResourceLimits(resources: RemainingResources): string | undefined {
|
|
11
|
-
if (resources.budgetUsd !== undefined && resources.budgetUsd <= 0) return formatError("budget exhausted");
|
|
12
10
|
if (resources.timeoutMs !== undefined && resources.timeoutMs <= 0) return formatError("timeout exhausted");
|
|
13
11
|
return undefined;
|
|
14
12
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/** Shared configuration + runtime types for the RLM engine. */
|
|
2
2
|
|
|
3
3
|
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
4
|
-
import type { AskAnswer, AskQuestion } from "../sandbox/protocol.ts";
|
|
5
|
-
import type { ReconstructResult } from "../state/resume.ts";
|
|
6
4
|
|
|
7
5
|
export interface Sampling {
|
|
8
6
|
readonly maxTokens?: number;
|
|
@@ -10,17 +8,6 @@ export interface Sampling {
|
|
|
10
8
|
readonly reasoning?: ThinkingLevel;
|
|
11
9
|
}
|
|
12
10
|
|
|
13
|
-
export interface RunLogConfig {
|
|
14
|
-
/** Default: true — always-on, opt-out. */
|
|
15
|
-
readonly enabled?: boolean;
|
|
16
|
-
/** Default: ".rlm/runs". Directory under cwd for run artifacts. */
|
|
17
|
-
readonly dir?: string;
|
|
18
|
-
/** Default: true — whether to write sandbox.pkl snapshots. */
|
|
19
|
-
readonly snapshot?: boolean;
|
|
20
|
-
/** Default: 50 — prune oldest runs beyond this count on each new run. */
|
|
21
|
-
readonly maxRuns?: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
11
|
export interface RlmConfig {
|
|
25
12
|
/** Persistent editor-routing mode; when enabled, plain interactive prompts use RLM. */
|
|
26
13
|
readonly enabled: boolean;
|
|
@@ -34,10 +21,11 @@ export interface RlmConfig {
|
|
|
34
21
|
readonly requestTimeoutMs: number;
|
|
35
22
|
/** Concurrency pool for *_batched sub-calls. */
|
|
36
23
|
readonly maxConcurrentSubcalls: number;
|
|
24
|
+
/** Concurrent recursive child engines admitted per depth. Lower than maxConcurrentSubcalls:
|
|
25
|
+
* each child is a Python subprocess holding its own copy of the inherited context. */
|
|
26
|
+
readonly maxConcurrentChildren: number;
|
|
37
27
|
/** Reject sub-LLM prompts larger than this many chars. */
|
|
38
28
|
readonly maxPromptChars: number;
|
|
39
|
-
/** Max USD spend across the whole tree before the engine stops (undefined = no cap). */
|
|
40
|
-
readonly maxBudgetUsd?: number;
|
|
41
29
|
/** Max wall-clock ms across the whole tree before the engine stops (undefined = no cap). */
|
|
42
30
|
readonly maxTimeoutMs?: number;
|
|
43
31
|
/** Max total input+output tokens across the whole tree before the engine stops (undefined = no cap). */
|
|
@@ -46,10 +34,6 @@ export interface RlmConfig {
|
|
|
46
34
|
readonly maxErrors?: number;
|
|
47
35
|
/** Append the orchestrator addendum to the system prompt. */
|
|
48
36
|
readonly orchestrator: boolean;
|
|
49
|
-
/** Enable the phase pipeline (advance_phase + stall nags) at depth 0. */
|
|
50
|
-
readonly pipeline: boolean;
|
|
51
|
-
/** Max validate→blueprint corrective re-entries when validation reports blockers (default 2). */
|
|
52
|
-
readonly maxBackwardJumps: number;
|
|
53
37
|
/** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
|
|
54
38
|
readonly compaction: boolean;
|
|
55
39
|
/** Compact when estimated history tokens reach this fraction of the model's context window. */
|
|
@@ -58,10 +42,6 @@ export interface RlmConfig {
|
|
|
58
42
|
readonly python: string;
|
|
59
43
|
/** Worker startup wait before treating sandbox init as failed (ms). */
|
|
60
44
|
readonly sandboxInitTimeoutMs: number;
|
|
61
|
-
/** Allow ask_user_question() calls from the root REPL. */
|
|
62
|
-
readonly askUserQuestion: boolean;
|
|
63
|
-
/** Allow todo() calls from the REPL. */
|
|
64
|
-
readonly todo: boolean;
|
|
65
45
|
/** Enable the load_library() REPL scaffold (external dirs/files/git repos as extra context slots). */
|
|
66
46
|
readonly libraryLoader: boolean;
|
|
67
47
|
/** ThinkingLevel for the root smart model (set via /rlm-config). */
|
|
@@ -76,8 +56,6 @@ export interface RlmConfig {
|
|
|
76
56
|
readonly subSystemPrompt?: string;
|
|
77
57
|
/** Sampling for sub-LLM (worker) calls. */
|
|
78
58
|
readonly subSampling: Readonly<Sampling>;
|
|
79
|
-
/** Optional run-state persistence configuration. Enabled by default. */
|
|
80
|
-
readonly runLog?: RunLogConfig;
|
|
81
59
|
}
|
|
82
60
|
|
|
83
61
|
/** Input to a (headless) RLM run. */
|
|
@@ -92,12 +70,8 @@ export interface RlmInput {
|
|
|
92
70
|
readonly parentNodeId?: string;
|
|
93
71
|
/** "provider/id" — overrides the root model for this run (set by recursive rlm_query). */
|
|
94
72
|
readonly modelOverride?: string;
|
|
95
|
-
/** Remaining budget for this subtree (set by parent from its LimitGuard). */
|
|
96
|
-
readonly remainingBudgetUsd?: number;
|
|
97
73
|
/** Remaining timeout for this subtree (set by parent from its LimitGuard). */
|
|
98
74
|
readonly remainingTimeoutMs?: number;
|
|
99
|
-
/** Depth-0 resume payload — controller rebuilds this from the trail's `reconstructRlmState()`. */
|
|
100
|
-
readonly resume?: ReconstructResult & { readonly ok: true };
|
|
101
75
|
}
|
|
102
76
|
|
|
103
77
|
/** Result of a completed RLM run. */
|
|
@@ -111,11 +85,4 @@ export interface RlmResult {
|
|
|
111
85
|
}
|
|
112
86
|
|
|
113
87
|
/** A function that runs an RLM to completion — used to wire recursion (rlm_query). */
|
|
114
|
-
export interface InteractiveDeps {
|
|
115
|
-
/** Called when the sandbox issues ask_user_question; undefined = feature disabled. */
|
|
116
|
-
readonly onAskUserQuestion?: (questions: readonly AskQuestion[]) => Promise<AskAnswer[]>;
|
|
117
|
-
/** Called when the sandbox issues todo; undefined = feature disabled. */
|
|
118
|
-
readonly onTodo?: (action: string, params: Record<string, unknown>) => Promise<string>;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
88
|
export type RunRlm = (input: RlmInput) => Promise<RlmResult>;
|
package/src/index.ts
CHANGED
|
@@ -7,17 +7,22 @@ import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
|
|
|
7
7
|
import { createRlmTool } from "./tool/rlm-tool.ts";
|
|
8
8
|
import { createReplTool } from "./tool/repl-tool.ts";
|
|
9
9
|
import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts";
|
|
10
|
-
import { RlmController
|
|
10
|
+
import { RlmController } from "./mode/rlm-mode.ts";
|
|
11
|
+
import { cheapestModel } from "./mode/llm-model.ts";
|
|
11
12
|
import { postRlmGuide } from "./ui/intro.ts";
|
|
12
13
|
import { setRlmModeStatus } from "./ui/status.ts";
|
|
13
14
|
import { markdownTheme } from "./ui/theme-adapter.ts";
|
|
14
15
|
import { SandboxManager } from "./sandbox/sandbox-manager.ts";
|
|
16
|
+
import { createSubcallGates } from "./util/concurrency.ts";
|
|
17
|
+
import { BackgroundTasks } from "./tool/background-tasks.ts";
|
|
15
18
|
import { packRepository, formatForLLM, serializeForSandbox } from "./context/repomix-context.ts";
|
|
16
|
-
import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/
|
|
19
|
+
import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/native.ts";
|
|
17
20
|
import { bashCommandFromInput, isFileReadingCommand, capToolResultText, BASH_BLOCK_REASON } from "./mode/native-guards.ts";
|
|
18
21
|
import { errorMessage } from "./util/errors.ts";
|
|
19
22
|
|
|
20
23
|
const BLOCKED_NATIVE_TOOLS = Object.freeze(new Set(["read", "grep"]));
|
|
24
|
+
/** How often to keep the parent sandbox's request watchdog alive during detached work. */
|
|
25
|
+
const WATCHDOG_HEARTBEAT_MS = 30_000;
|
|
21
26
|
const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls"]));
|
|
22
27
|
|
|
23
28
|
export default function rlmExtension(pi: ExtensionAPI): void {
|
|
@@ -31,8 +36,27 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
31
36
|
python: config.python,
|
|
32
37
|
sandboxInitTimeoutMs: config.sandboxInitTimeoutMs,
|
|
33
38
|
maxPromptChars: config.maxPromptChars,
|
|
39
|
+
// Same wall budget as the parent request watchdog — a stalled sub-call should surface
|
|
40
|
+
// inside the cell rather than hang the session forever.
|
|
41
|
+
awaitTimeoutS: Math.round(config.requestTimeoutMs / 1000),
|
|
34
42
|
onSandboxDiscarded: () => { onSandboxDiscardExtra?.(); },
|
|
35
43
|
});
|
|
44
|
+
// One admission gate for the whole session: spawn() lets the sandbox put many requests on
|
|
45
|
+
// the wire at once, so nothing smaller than session scope actually bounds fan-out.
|
|
46
|
+
const gates = createSubcallGates(config.maxConcurrentSubcalls, config.maxConcurrentChildren);
|
|
47
|
+
const background = new BackgroundTasks({
|
|
48
|
+
maxTimeoutMs: config.maxTimeoutMs,
|
|
49
|
+
maxTokens: config.maxTokens,
|
|
50
|
+
maxErrors: config.maxErrors,
|
|
51
|
+
});
|
|
52
|
+
// A detached child works in its OWN sandbox, so this one sees no frames and its request
|
|
53
|
+
// watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
|
|
54
|
+
// with it. Keep it alive while detached work is genuinely in flight.
|
|
55
|
+
const watchdogHeartbeat = setInterval(() => {
|
|
56
|
+
if (background.pending > 0) sandboxManager.refreshWatchdog();
|
|
57
|
+
}, WATCHDOG_HEARTBEAT_MS);
|
|
58
|
+
watchdogHeartbeat.unref();
|
|
59
|
+
|
|
36
60
|
let packedContextText: string | undefined;
|
|
37
61
|
let contextPackPromise: Promise<string | undefined> | undefined;
|
|
38
62
|
const ensureRepositoryContext = async (cwd: string): Promise<string | undefined> => {
|
|
@@ -55,7 +79,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
55
79
|
const settingsReady = loadSettings()
|
|
56
80
|
.then((persisted) => {
|
|
57
81
|
controller.config = mergeConfig(persisted.config);
|
|
58
|
-
controller.
|
|
82
|
+
controller.savedLlmRef = persisted.llm;
|
|
59
83
|
})
|
|
60
84
|
.catch((err) => {
|
|
61
85
|
console.warn(`[rlm] settings load failed: ${errorMessage(err)}`);
|
|
@@ -96,24 +120,37 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
96
120
|
const flag = pi.getFlag("rlm");
|
|
97
121
|
if (typeof flag === "boolean") controller.setConfig(Object.freeze({ ...controller.config, enabled: flag }));
|
|
98
122
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
123
|
+
// Reload the catalog before the worker-model pick below reads it. Newer pi builds make
|
|
124
|
+
// `getAvailable()` an async-populated snapshot that starts empty, and picking from an empty
|
|
125
|
+
// catalog silently falls back to the root model. Called with no arguments and awaited so it
|
|
126
|
+
// is valid whether `refresh` returns void (current) or a promise (newer); fail-soft, because
|
|
127
|
+
// a refresh error must not abort session start.
|
|
128
|
+
try {
|
|
129
|
+
await ctx.modelRegistry.refresh();
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.warn(`[rlm] model registry refresh failed: ${errorMessage(err)}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (controller.savedLlmRef) {
|
|
135
|
+
const resolved = resolveModelId(ctx.modelRegistry, controller.savedLlmRef);
|
|
136
|
+
if (resolved) controller.llmModel = resolved;
|
|
102
137
|
}
|
|
103
138
|
|
|
104
139
|
// Re-register repl tool each session to pick up model provider changes
|
|
105
|
-
const
|
|
140
|
+
const llmModel = controller.llmModel ?? cheapestModel(ctx.modelRegistry) ?? ctx.model;
|
|
106
141
|
const model = ctx.model;
|
|
107
|
-
if (
|
|
142
|
+
if (llmModel && model) {
|
|
108
143
|
try {
|
|
109
144
|
pi.registerTool(createReplTool({
|
|
110
145
|
sandboxManager,
|
|
111
146
|
model,
|
|
112
|
-
|
|
147
|
+
llmModel,
|
|
113
148
|
getModel: () => controller.resolveModels(ctx)?.model,
|
|
114
|
-
|
|
149
|
+
getLlmModel: () => controller.resolveModels(ctx)?.llm,
|
|
115
150
|
registry: ctx.modelRegistry,
|
|
116
151
|
getConfig: () => controller.config,
|
|
152
|
+
gates,
|
|
153
|
+
background,
|
|
117
154
|
registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
|
|
118
155
|
ensureContext: async () => {
|
|
119
156
|
const contextText = await ensureRepositoryContext(ctx.cwd ?? process.cwd());
|
|
@@ -224,6 +261,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
224
261
|
// ── Session shutdown: cleanup ──
|
|
225
262
|
pi.on("session_shutdown", async () => {
|
|
226
263
|
controller.abort();
|
|
264
|
+
clearInterval(watchdogHeartbeat);
|
|
265
|
+
background.dispose();
|
|
227
266
|
await sandboxManager.dispose();
|
|
228
267
|
contextInjected = false;
|
|
229
268
|
packedContextText = undefined;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-LLM model ranking — "cheapest available", with free models winning outright.
|
|
3
|
+
*
|
|
4
|
+
* Pi's `ModelCost` is non-nullable (`packages/ai/src/types.ts`), so a free model is a literal 0,
|
|
5
|
+
* not a null. That makes plain price sorting ambiguous rather than wrong: subscription and
|
|
6
|
+
* token-plan providers also publish 0, and a stable sort would hand back whichever 0-cost entry
|
|
7
|
+
* happened to be first in catalog order. The tie-breaks below are what actually pick a usable
|
|
8
|
+
* free model — and what make the pick identical across sessions and catalog reorderings.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
12
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
/** $/Mtok, input-weighted 3:1 — a sub-call sends a file body and gets back a sentence. */
|
|
15
|
+
function priceOf(model: Model<Api>): number {
|
|
16
|
+
const { input, output, cacheRead } = model.cost;
|
|
17
|
+
return input * 3 + output + cacheRead;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True when the model costs nothing to call on any axis. */
|
|
21
|
+
export function isFreeModel(model: Model<Api>): boolean {
|
|
22
|
+
return priceOf(model) === 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Negative when `a` is the better sub-LLM.
|
|
27
|
+
*
|
|
28
|
+
* Window before maxTokens before id: a free model with a 4K context is useless for bulk reading,
|
|
29
|
+
* so price alone must not decide. The final id comparison exists only to make the result
|
|
30
|
+
* deterministic — without it the pick drifts whenever a provider reorders its catalog.
|
|
31
|
+
*/
|
|
32
|
+
export function compareLlm(a: Model<Api>, b: Model<Api>): number {
|
|
33
|
+
return (priceOf(a) - priceOf(b))
|
|
34
|
+
|| (b.contextWindow - a.contextWindow)
|
|
35
|
+
|| (b.maxTokens - a.maxTokens)
|
|
36
|
+
|| `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Best sub-LLM among the models whose provider has configured auth.
|
|
41
|
+
*
|
|
42
|
+
* Single pass rather than `[...models].sort()[0]`: the copy and the sort both allocate for a
|
|
43
|
+
* result that is one element.
|
|
44
|
+
*/
|
|
45
|
+
export function cheapestModel(registry: ModelRegistry): Model<Api> | undefined {
|
|
46
|
+
const models = registry.getAvailable();
|
|
47
|
+
let best: Model<Api> | undefined;
|
|
48
|
+
for (let i = 0; i < models.length; i++) {
|
|
49
|
+
const model = models[i];
|
|
50
|
+
if (model === undefined) continue;
|
|
51
|
+
if (best === undefined || compareLlm(model, best) < 0) best = model;
|
|
52
|
+
}
|
|
53
|
+
return best;
|
|
54
|
+
}
|
package/src/mode/rlm-mode.ts
CHANGED
|
@@ -1,42 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RlmController — holds RLM config + chosen models.
|
|
3
3
|
*
|
|
4
|
-
* The engine drives the root model turn-by-turn over ```repl``` blocks with full
|
|
4
|
+
* The engine drives the root model turn-by-turn over ```repl``` blocks with full token/
|
|
5
5
|
* timeout/error guards, compaction, and a finalize fallback. `start()` returns a RunHandle with
|
|
6
6
|
* the completion promise.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
10
|
-
import type { ExtensionContext
|
|
11
|
-
import { DEFAULT_RUN_DIR } from "../config/defaults.ts";
|
|
10
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
11
|
import { modelRef, resolveModelId, saveSettings } from "../config/settings.ts";
|
|
13
12
|
import { createEngine } from "../core/engine.ts";
|
|
14
13
|
import { limitsFromConfig } from "../core/limits.ts";
|
|
15
|
-
import type {
|
|
16
|
-
import type { ReconstructResult } from "../state/resume.ts";
|
|
14
|
+
import type { RlmConfig, RlmResult } from "../core/types.ts";
|
|
17
15
|
import { packRepository, serializeForSandbox } from "../context/repomix-context.ts";
|
|
18
16
|
import { RlmEmitter } from "../tool/rlm-events.ts";
|
|
19
17
|
import { formatError } from "../util/errors.ts";
|
|
20
|
-
|
|
21
|
-
export function cheapestModel(registry: ModelRegistry): Model<Api> | undefined {
|
|
22
|
-
const models = registry.getAvailable();
|
|
23
|
-
if (models.length === 0) return undefined;
|
|
24
|
-
return [...models].sort((a, b) => a.cost.input + a.cost.output - (b.cost.input + b.cost.output))[0];
|
|
25
|
-
}
|
|
18
|
+
import { cheapestModel } from "./llm-model.ts";
|
|
26
19
|
|
|
27
20
|
export interface RunHandle {
|
|
28
21
|
readonly abort: () => void;
|
|
29
22
|
readonly done: Promise<RlmResult>;
|
|
30
23
|
}
|
|
31
24
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
export interface StartInput {
|
|
26
|
+
readonly rootPrompt: string;
|
|
27
|
+
readonly context: unknown;
|
|
28
|
+
}
|
|
36
29
|
|
|
37
30
|
export class RlmController {
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
llmModel: Model<Api> | undefined;
|
|
32
|
+
savedLlmRef: string | undefined;
|
|
40
33
|
private active: AbortController | null = null;
|
|
41
34
|
|
|
42
35
|
constructor(public config: RlmConfig) {}
|
|
@@ -65,7 +58,7 @@ export class RlmController {
|
|
|
65
58
|
async persist(): Promise<boolean> {
|
|
66
59
|
return await saveSettings({
|
|
67
60
|
config: this.config,
|
|
68
|
-
|
|
61
|
+
llm: modelRef(this.llmModel) ?? this.savedLlmRef,
|
|
69
62
|
});
|
|
70
63
|
}
|
|
71
64
|
|
|
@@ -77,15 +70,15 @@ export class RlmController {
|
|
|
77
70
|
this.active?.abort();
|
|
78
71
|
}
|
|
79
72
|
|
|
80
|
-
resolveModels(ctx: ExtensionContext): { model: Model<Api>;
|
|
81
|
-
if (!this.
|
|
73
|
+
resolveModels(ctx: ExtensionContext): { model: Model<Api>; llm: Model<Api> } | undefined {
|
|
74
|
+
if (!this.llmModel && this.savedLlmRef) this.llmModel = resolveModelId(ctx.modelRegistry, this.savedLlmRef);
|
|
82
75
|
const model = ctx.model ?? cheapestModel(ctx.modelRegistry);
|
|
83
76
|
if (!model) return undefined;
|
|
84
|
-
const
|
|
85
|
-
return { model,
|
|
77
|
+
const llm = this.llmModel ?? cheapestModel(ctx.modelRegistry) ?? model;
|
|
78
|
+
return { model, llm };
|
|
86
79
|
}
|
|
87
80
|
|
|
88
|
-
start(ctx: ExtensionContext, input: StartInput, emitter?: RlmEmitter
|
|
81
|
+
start(ctx: ExtensionContext, input: StartInput, emitter?: RlmEmitter): RunHandle {
|
|
89
82
|
const models = this.resolveModels(ctx);
|
|
90
83
|
if (!models) throw new Error("no model with configured auth is available");
|
|
91
84
|
if (this.active) throw new Error("RLM run already in progress"); // QC: mutual-exclusion guard
|
|
@@ -93,50 +86,26 @@ export class RlmController {
|
|
|
93
86
|
const abortController = new AbortController();
|
|
94
87
|
this.active = abortController;
|
|
95
88
|
|
|
96
|
-
const runState = this.config.runLog?.enabled !== false
|
|
97
|
-
? { cwd: ctx.cwd ?? process.cwd(), dir: this.config.runLog?.dir ?? DEFAULT_RUN_DIR, snapshot: this.config.runLog?.snapshot !== false }
|
|
98
|
-
: undefined;
|
|
99
|
-
|
|
100
89
|
const done = (async () => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
contextValue = serializeForSandbox(result.value);
|
|
110
|
-
} else {
|
|
111
|
-
contextValue = formatError(`failed to pack repository — ${result.error}`);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
engineInput = {
|
|
115
|
-
rootPrompt: input.rootPrompt,
|
|
116
|
-
context: contextValue,
|
|
117
|
-
depth: 0,
|
|
118
|
-
};
|
|
119
|
-
} else {
|
|
120
|
-
engineInput = {
|
|
121
|
-
rootPrompt: input.resume.header.rootPrompt,
|
|
122
|
-
context: input.context, // B5: load the actual context from the sidecar, not ""
|
|
123
|
-
depth: 0,
|
|
124
|
-
resume: input.resume,
|
|
125
|
-
};
|
|
90
|
+
// Auto-pack empty/undefined context via repomix; pass explicit context through.
|
|
91
|
+
let contextValue: unknown = input.context;
|
|
92
|
+
if (contextValue === undefined || (typeof contextValue === "string" && contextValue.trim() === "")) {
|
|
93
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
94
|
+
const result = await packRepository(cwd, abortController.signal);
|
|
95
|
+
contextValue = result.ok
|
|
96
|
+
? serializeForSandbox(result.value)
|
|
97
|
+
: formatError(`failed to pack repository — ${result.error}`);
|
|
126
98
|
}
|
|
127
99
|
const engine = createEngine({
|
|
128
100
|
model: models.model,
|
|
129
|
-
|
|
101
|
+
llmModel: models.llm,
|
|
130
102
|
registry: ctx.modelRegistry,
|
|
131
103
|
config: this.config,
|
|
132
104
|
signal: abortController.signal,
|
|
133
105
|
emitter: emitter ?? new RlmEmitter(),
|
|
134
|
-
runState,
|
|
135
|
-
onAskUserQuestion: interactive?.onAskUserQuestion,
|
|
136
|
-
onTodo: interactive?.onTodo,
|
|
137
106
|
limits: limitsFromConfig(this.config),
|
|
138
107
|
});
|
|
139
|
-
return await engine(
|
|
108
|
+
return await engine({ rootPrompt: input.rootPrompt, context: contextValue, depth: 0 });
|
|
140
109
|
})().finally(() => {
|
|
141
110
|
if (this.active === abortController) this.active = null;
|
|
142
111
|
});
|