@hicaru/pi-rlm 0.1.9 → 0.2.1

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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/bridge/fallback-todo.ts +12 -1
  3. package/src/bridge/subcall-handlers.ts +336 -0
  4. package/src/commands/rlm-config.ts +8 -8
  5. package/src/commands/rlm.ts +48 -12
  6. package/src/config/defaults.ts +4 -1
  7. package/src/config/settings.ts +33 -3
  8. package/src/context/repomix-context.ts +5 -10
  9. package/src/core/answer.ts +4 -3
  10. package/src/core/artifacts.ts +4 -3
  11. package/src/core/engine.ts +101 -267
  12. package/src/core/gates.ts +3 -3
  13. package/src/core/limits.ts +19 -1
  14. package/src/core/pipeline-handlers.ts +319 -0
  15. package/src/core/pipeline.ts +2 -2
  16. package/src/core/types.ts +25 -27
  17. package/src/index.ts +63 -17
  18. package/src/mode/rlm-mode.ts +8 -11
  19. package/src/prompts/system.ts +164 -52
  20. package/src/prompts/user.ts +1 -5
  21. package/src/sandbox/protocol.ts +6 -7
  22. package/src/sandbox/sandbox-manager.ts +25 -11
  23. package/src/sandbox/sandbox.ts +93 -22
  24. package/src/sandbox/worker.py +798 -66
  25. package/src/state/paths.ts +1 -1
  26. package/src/state/reads.ts +12 -4
  27. package/src/state/resume.ts +5 -11
  28. package/src/text/parsing.ts +0 -6
  29. package/src/tool/background-tasks.ts +95 -0
  30. package/src/tool/repl-details.ts +2 -0
  31. package/src/tool/repl-tool.ts +223 -318
  32. package/src/tool/rlm-details.ts +0 -10
  33. package/src/tool/rlm-events.ts +10 -2
  34. package/src/tool/rlm-tool.ts +18 -31
  35. package/src/tool/subcall-render.ts +75 -11
  36. package/src/tool/subcall-store.ts +57 -1
  37. package/src/ui/config-panel.ts +41 -21
  38. package/src/ui/intro.ts +2 -1
  39. package/src/ui/status.ts +8 -5
  40. package/src/ui/theme-adapter.ts +36 -0
  41. package/src/ui/theme.ts +0 -25
  42. package/src/util/concurrency.ts +87 -13
  43. package/src/util/trace.ts +42 -0
  44. package/src/bridge/llm-query.ts +0 -133
  45. package/src/bridge/rlm-query.ts +0 -122
  46. package/src/mode/input-router.ts +0 -23
@@ -1,15 +1,89 @@
1
- /** Fixed-size concurrency pool: run `fn` over `items` with at most `limit` in flight, preserving order. */
2
- export async function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T, idx: number) => Promise<R>): Promise<R[]> {
3
- const out = new Array<R>(items.length);
4
- let next = 0;
5
- const worker = async (): Promise<void> => {
6
- while (true) {
7
- const index = next;
8
- next += 1;
9
- if (index >= items.length) return;
10
- out[index] = await fn(items[index], index);
1
+ /**
2
+ * Sub-call admission control.
3
+ *
4
+ * `spawn()` lets the sandbox put many requests on the wire at once, so a per-call pool no
5
+ * longer bounds anything: one `llm_query_chunked` over a large file posts every batch
6
+ * simultaneously, and each batch fans out again. The bound has to be session-wide, which is
7
+ * what `SubcallGates` is — constructed once at the composition root and shared by every
8
+ * handler.
9
+ */
10
+
11
+ /**
12
+ * Counting semaphore: at most `limit` holders at once, FIFO.
13
+ *
14
+ * NOT re-entrant. A leaf completion takes exactly ONE slot, in `complete1`; wrapping a batch in
15
+ * a second acquisition of this same gate deadlocks as soon as the batch reaches `limit`
16
+ * prompts — the outer holders fill the gate and each waits for an inner slot nothing can free.
17
+ *
18
+ * FIFO means a large fan-out is not starved, but also that it is not preempted: a
19
+ * 500-prompt `llm_query_chunked` holds the queue until it drains, so an interactive
20
+ * `llm_query` issued behind it waits for the whole thing. Acceptable while sub-calls are
21
+ * uniform in priority; revisit if an interactive tier is ever added.
22
+ */
23
+ export class Semaphore {
24
+ private active = 0;
25
+ private readonly waiters: Array<() => void> = [];
26
+
27
+ constructor(private readonly limit: number) {}
28
+
29
+ /** Hold a slot for the duration of `fn`. */
30
+ async run<T>(fn: () => Promise<T>): Promise<T> {
31
+ // `while`, not `if`: a caller arriving between the decrement below and a woken waiter's
32
+ // resumption would otherwise slip past the limit.
33
+ while (this.active >= this.limit) {
34
+ await new Promise<void>((resolve) => { this.waiters.push(resolve); });
11
35
  }
12
- };
13
- await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
14
- return out;
36
+ this.active += 1;
37
+ try {
38
+ return await fn();
39
+ } finally {
40
+ this.active -= 1;
41
+ this.waiters.shift()?.();
42
+ }
43
+ }
44
+
45
+ /** In-flight holders. Exposed for tests asserting the bound. */
46
+ get inFlight(): number {
47
+ return this.active;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * One semaphore per recursion depth.
53
+ *
54
+ * A single shared gate would deadlock: `limit` rlm_query children holding every slot, each
55
+ * blocked waiting for a grandchild that can never be admitted. Splitting by depth breaks the
56
+ * cycle — a holder at depth k only ever waits on depth k+1. Leaf LLM calls are terminal and
57
+ * never re-enter, so they safely share one process-wide gate.
58
+ */
59
+ export class DepthGates {
60
+ private readonly gates = new Map<number, Semaphore>();
61
+
62
+ constructor(private readonly limit: number) {}
63
+
64
+ at(depth: number): Semaphore {
65
+ let gate = this.gates.get(depth);
66
+ if (gate === undefined) {
67
+ gate = new Semaphore(this.limit);
68
+ this.gates.set(depth, gate);
69
+ }
70
+ return gate;
71
+ }
72
+ }
73
+
74
+ /** Session-wide sub-call admission. Construct once; pass explicitly — never default one in. */
75
+ export interface SubcallGates {
76
+ /** llm_query / llm_query_batched completions — terminal, so one shared gate. */
77
+ readonly leaf: Semaphore;
78
+ /** Recursive child engines — one gate per depth, see DepthGates. */
79
+ readonly rlm: DepthGates;
80
+ }
81
+
82
+ /**
83
+ * Worst case is `maxDepth × limit` concurrent child engines (each owning a Python
84
+ * subprocess) plus `limit` leaf completions, so keep `limit` modest — see
85
+ * DEFAULT_CONFIG.maxConcurrentSubcalls.
86
+ */
87
+ export function createSubcallGates(limit: number): SubcallGates {
88
+ return Object.freeze({ leaf: new Semaphore(limit), rlm: new DepthGates(limit) });
15
89
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * RLM trace — one JSONL line per interesting event, for the E2E harness and for post-mortem of
3
+ * a hung run. Off unless RLM_TRACE_FILE is set; when off, every call costs one boolean check.
4
+ *
5
+ * Purely observational: it subscribes to the RlmEmitter that already exists and to the sandbox
6
+ * frames that already cross the pipe. No behaviour is duplicated here.
7
+ */
8
+ import { appendFileSync } from "node:fs";
9
+ import type { RlmEmitter } from "../tool/rlm-events.ts";
10
+
11
+ const FILE = process.env.RLM_TRACE_FILE;
12
+
13
+ /** Check this before building payloads on hot paths. */
14
+ export const traceEnabled: boolean = typeof FILE === "string" && FILE.length > 0;
15
+
16
+ const START = Date.now();
17
+
18
+ /** Append one event. Fail-soft: tracing must never break a run (state/writes.ts convention). */
19
+ export function trace(kind: string, data: Record<string, unknown> = {}): void {
20
+ if (!traceEnabled || FILE === undefined) return;
21
+ const now = Date.now();
22
+ try {
23
+ appendFileSync(FILE, `${JSON.stringify({ t: now, rel: now - START, pid: process.pid, kind, ...data })}\n`);
24
+ } catch {
25
+ /* ignore */
26
+ }
27
+ }
28
+
29
+ /** Mirror one emitter's sub-call lifecycle into the trace. Returns an unsubscribe fn. */
30
+ export function attachTracer(emitter: RlmEmitter, scope: "turn" | "background"): () => void {
31
+ if (!traceEnabled) return () => {};
32
+ // Rename e.kind → subcallKind so it does not overwrite the outer event kind
33
+ // (`subcall.created` / `subcall.updated`) when `trace` spreads `data` after `kind`.
34
+ const offs = [
35
+ emitter.onSubcallCreated((e) => {
36
+ const { kind: subcallKind, ...rest } = e;
37
+ trace("subcall.created", { scope, subcallKind, ...rest });
38
+ }),
39
+ emitter.onSubcallUpdated((e) => trace("subcall.updated", { scope, ...e })),
40
+ ];
41
+ return () => { for (const off of offs) off(); };
42
+ }
@@ -1,133 +0,0 @@
1
- /**
2
- * The `llm_query` / `llm_query_batched` bridge: turns sandbox sub-LLM interrupts into
3
- * real (serverless) completions on the configured *worker* model, reporting each call to the
4
- * RlmEmitter for progressive TUI re-rendering.
5
- *
6
- * Caps enforce the divide-and-conquer budget from the RLM method: per-prompt size and batch
7
- * fan-out are bounded, and batches run through a fixed-size concurrency pool.
8
- */
9
-
10
- import type { Api, Model, Usage } from "@earendil-works/pi-ai";
11
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
12
- import type { RlmEmitter } from "../tool/rlm-events.ts";
13
- import { modelRef, resolveModelId } from "../config/settings.ts";
14
- import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
15
- import type { Sampling } from "../core/types.ts";
16
- import { type ChatMsg, modelComplete } from "./model.ts";
17
- import { previewText } from "../text/preview.ts";
18
- import { formatError, isErrorText } from "../util/errors.ts";
19
- import { mapPool } from "../util/concurrency.ts";
20
-
21
- export interface LlmBridgeOptions {
22
- readonly workerModel: Model<Api>;
23
- readonly registry: ModelRegistry;
24
- readonly subSystem?: string;
25
- readonly maxPromptChars?: number;
26
- readonly maxConcurrent?: number;
27
- readonly sampling?: Sampling;
28
- readonly signal?: AbortSignal;
29
- readonly onUsage?: (usage: Usage, model: Model<Api>) => void;
30
- /** Parent run's remaining budget/timeout; checked before every sub-call. */
31
- readonly remainingBudget?: () => RemainingResources;
32
- /** Live RlmDetails reporting via onUpdate. */
33
- readonly emitter?: RlmEmitter;
34
- readonly parentId?: string;
35
- readonly depth?: number;
36
- }
37
-
38
- const DEFAULT_MAX_PROMPT_CHARS = 400_000;
39
- const DEFAULT_MAX_CONCURRENT = 4;
40
-
41
- export interface LlmBridge {
42
- llmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
43
- llmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
44
- }
45
-
46
- 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
- const displayModel = (model: string | null): string =>
51
- modelRef(model ? (resolveModelId(opts.registry, model) ?? opts.workerModel) : opts.workerModel) ?? opts.workerModel.id;
52
-
53
- // Run one completion; report cost/tokens via `track` (a per-call or per-batch accumulator).
54
- async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
55
- const rem = opts.remainingBudget?.();
56
- if (rem !== undefined) {
57
- const limitError = checkResourceLimits(rem);
58
- if (limitError !== undefined) return limitError;
59
- }
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.`);
62
- }
63
- const resolved = model ? resolveModelId(opts.registry, model) : undefined;
64
- if (model && !resolved) return formatError(`unknown model override '${model}'`);
65
- try {
66
- const messages: ChatMsg[] = [{ role: "user", content: prompt }];
67
- const res = await modelComplete(messages, {
68
- model: resolved ?? opts.workerModel,
69
- registry: opts.registry,
70
- system: opts.subSystem,
71
- maxTokens: opts.sampling?.maxTokens,
72
- temperature: opts.sampling?.temperature,
73
- reasoning: opts.sampling?.reasoning,
74
- signal: opts.signal,
75
- });
76
- opts.onUsage?.(res.usage, resolved ?? opts.workerModel);
77
- track(res.usage);
78
- return res.text;
79
- } catch (err) {
80
- return formatError(err instanceof Error ? err.message : String(err));
81
- }
82
- }
83
-
84
- return {
85
- async llmQuery(prompt, model) {
86
- const id = emitter?.emitSubcallCreated({
87
- kind: "llm", parentId: opts.parentId, label: "llm_query",
88
- model: displayModel(model), args: `prompt: ${previewText(prompt)}`,
89
- depth: opts.depth ?? 0,
90
- });
91
- let cost = 0;
92
- let tokens = 0;
93
- const out = await complete1(prompt, model, (u) => {
94
- cost += u.cost.total;
95
- tokens += u.totalTokens;
96
- });
97
- if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
98
- status: isErrorText(out) ? "error" : "done",
99
- costUsd: cost, tokens, resultPreview: previewText(out),
100
- detail: isErrorText(out) ? out : undefined,
101
- });
102
- return out;
103
- },
104
-
105
- async llmQueryBatched(prompts, model) {
106
- const id = emitter?.emitSubcallCreated({
107
- kind: "batch", parentId: opts.parentId, label: `llm_query ×${prompts.length}`,
108
- model: displayModel(model), args: `prompt: ${previewText(prompts[0] ?? "")}`,
109
- depth: opts.depth ?? 0,
110
- });
111
- let cost = 0;
112
- let tokens = 0;
113
- const out = await mapPool(prompts, maxConcurrent, (p) =>
114
- complete1(p, model, (u) => {
115
- cost += u.cost.total;
116
- tokens += u.totalTokens;
117
- }),
118
- );
119
- const failed = out.filter(isErrorText).length;
120
- const error = failed === out.length && out.length > 0
121
- ? `all ${out.length} sub-calls failed`
122
- : failed > 0 ? `${failed}/${out.length} sub-calls failed` : undefined;
123
- const firstPreview = previewText(out[0] ?? "");
124
- const resultPreview = out.length > 1 ? `${firstPreview} (+${out.length - 1} more)` : firstPreview;
125
- if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
126
- status: error ? "error" : "done", costUsd: cost, tokens,
127
- resultPreview, detail: error,
128
- failedCount: failed, totalCount: out.length,
129
- });
130
- return out;
131
- },
132
- };
133
- }
@@ -1,122 +0,0 @@
1
- /**
2
- * The `rlm_query` recursion bridge.
3
- *
4
- * A child RLM gets its own sandbox and iterates over the prompt as its context. At/over the
5
- * depth cap it degrades to a plain `llm_query` (ported from rlm/core/rlm.py `_subcall`). The
6
- * concurrency pool bounds parallel children for `rlm_query_batched`.
7
- *
8
- * `childRun` is the single spawn path shared by `rlmQuery` / `rlmQueryBatched`.
9
- */
10
-
11
- import type { RlmResult, RunRlm } from "../core/types.ts";
12
- import type { LlmBridge } from "./llm-query.ts";
13
- import type { RlmEmitter } from "../tool/rlm-events.ts";
14
- import { checkResourceLimits } from "../core/resource-limits.ts";
15
- import { formatError } from "../util/errors.ts";
16
- import { mapPool } from "../util/concurrency.ts";
17
-
18
- export interface ChildRunInput {
19
- readonly rootPrompt: string;
20
- readonly context: unknown;
21
- readonly depth: number;
22
- readonly label?: string;
23
- readonly model?: string | null;
24
- }
25
-
26
- export interface RlmHandlers {
27
- rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
28
- rlmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
29
- /** Full child-run result (answer + usage) for recursive spawns. */
30
- childRun(input: ChildRunInput): Promise<RlmResult>;
31
- }
32
-
33
- export interface RlmBridgeOptions {
34
- readonly run: RunRlm;
35
- readonly llm: LlmBridge;
36
- /** Live RlmDetails reporting via onUpdate. Required — replaces SubcallObserver for recursive subcalls. */
37
- readonly emitter: RlmEmitter;
38
- readonly maxDepth: number;
39
- readonly maxConcurrent: number;
40
- /** Parent subcall ID that this run is attached under. */
41
- readonly parentNodeId?: string;
42
- /** Returns the parent's remaining budget/timeout for seeding child runs. */
43
- readonly remainingBudget?: () => { readonly budgetUsd?: number; readonly timeoutMs?: number };
44
- /** Called with a child run's total cost/tokens so the parent LimitGuard debits it. */
45
- readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
46
- }
47
-
48
- function emptyResult(answer: string): RlmResult {
49
- return {
50
- answer,
51
- iterations: 0,
52
- costUsd: 0,
53
- inputTokens: 0,
54
- outputTokens: 0,
55
- durationMs: 0,
56
- };
57
- }
58
-
59
- export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
60
- async function childRun(input: ChildRunInput): Promise<RlmResult> {
61
- const childDepth = input.depth;
62
- // At the cap, a child RLM would just be an LM — short-circuit to a one-shot llm_query.
63
- // (Callers pass the absolute child depth; rlmQuery wraps with depth+1.)
64
- if (childDepth >= opts.maxDepth) {
65
- const answer = await opts.llm.llmQuery(
66
- input.rootPrompt || String(input.context),
67
- input.model ?? null,
68
- childDepth - 1,
69
- );
70
- return emptyResult(answer);
71
- }
72
- let subId: string | undefined;
73
- try {
74
- const rem = opts.remainingBudget?.() ?? {};
75
- // Pre-spawn guard: refuse if the parent's budget or timeout is already exhausted
76
- // (reference: _subcall checks remaining_budget/timeout before spawning).
77
- const limitError = checkResourceLimits(rem);
78
- if (limitError) return emptyResult(limitError);
79
- const label = input.label ?? "rlm_query";
80
- const detailSource = input.rootPrompt || String(input.context);
81
- subId = opts.emitter.emitSubcallCreated({
82
- kind: "rlm", parentId: opts.parentNodeId, label,
83
- model: input.model ?? undefined, detail: detailSource.slice(0, 60),
84
- depth: childDepth,
85
- });
86
- const res = await opts.run({
87
- rootPrompt: input.rootPrompt,
88
- context: input.context,
89
- depth: childDepth,
90
- parentNodeId: subId,
91
- modelOverride: input.model ?? undefined,
92
- remainingBudgetUsd: rem.budgetUsd,
93
- remainingTimeoutMs: rem.timeoutMs,
94
- });
95
- opts.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
96
- opts.emitter.emitSubcallUpdated({ id: subId,
97
- status: "done", resultPreview: res.answer.slice(0, 200),
98
- });
99
- return res;
100
- } catch (err) {
101
- const msg = err instanceof Error ? err.message : String(err);
102
- if (subId) opts.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
103
- return emptyResult(formatError(`child RLM failed - ${msg}`));
104
- }
105
- }
106
-
107
- async function child(prompt: string, model: string | null, depth: number): Promise<string> {
108
- const res = await childRun({
109
- rootPrompt: "",
110
- context: prompt,
111
- depth: depth + 1,
112
- model,
113
- });
114
- return res.answer;
115
- }
116
-
117
- return {
118
- rlmQuery: (prompt, model, depth) => child(prompt, model, depth),
119
- rlmQueryBatched: (prompts, model, depth) => mapPool(prompts, opts.maxConcurrent, (p) => child(p, model, depth)),
120
- childRun,
121
- };
122
- }
@@ -1,23 +0,0 @@
1
- import type { InputSource } from "@earendil-works/pi-coding-agent";
2
-
3
- export interface InputRouteState {
4
- readonly enabled: boolean;
5
- readonly busy: boolean;
6
- }
7
-
8
- export interface InputRouteEvent {
9
- readonly source: InputSource;
10
- readonly text: string;
11
- }
12
-
13
- export type InputRouteDecision = "continue" | "route" | "busy";
14
-
15
- export function decideRlmInputRoute(event: InputRouteEvent, state: InputRouteState): InputRouteDecision {
16
- const eligible = state.enabled && event.source === "interactive" && !event.text.trimStart().startsWith("/");
17
- if (!eligible) return "continue";
18
- return state.busy ? "busy" : "route";
19
- }
20
-
21
- export function shouldRouteRlmInput(event: InputRouteEvent, state: InputRouteState): boolean {
22
- return decideRlmInputRoute(event, state) === "route";
23
- }