@hicaru/pi-rlm 0.2.0 → 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.
@@ -7,13 +7,19 @@
7
7
  * subcall accumulation logic.
8
8
  */
9
9
  import type { RlmEmitter, SubcallCreatedEvent, SubcallUpdatedEvent } from "./rlm-events.ts";
10
- import type { RlmSubcall } from "./rlm-details.ts";
10
+ import type { RlmSubcall, SubcallStatus } from "./rlm-details.ts";
11
11
  import { EmitterListener } from "./emitter-listener.ts";
12
12
 
13
13
  type MutableSubcall = {
14
14
  -readonly [Key in keyof RlmSubcall]: RlmSubcall[Key];
15
15
  };
16
16
 
17
+ /** Accumulated cost/tokens, shared by getTotals() and takeSettledSubtrees(). */
18
+ export interface SubcallTotals {
19
+ readonly costUsd: number;
20
+ readonly tokens: number;
21
+ }
22
+
17
23
  export class SubcallStore extends EmitterListener {
18
24
  private readonly subcalls = new Map<string, MutableSubcall>();
19
25
 
@@ -82,6 +88,56 @@ export class SubcallStore extends EmitterListener {
82
88
  return { costUsd: this.totalCostUsd, tokens: this.totalTokens };
83
89
  }
84
90
 
91
+ /**
92
+ * Remove and return every fully-settled root subtree, with its cost/tokens subtracted
93
+ * from the running totals so the caller can add them without double-counting.
94
+ *
95
+ * A root whose subtree still has a running node stays put. That matters because
96
+ * `renderCollapsedSubcallTree` walks down from `parentId === undefined`: a subcall handed
97
+ * over without its parent has no path from a root and is silently dropped from the tree.
98
+ * Handing over whole subtrees is what keeps adopted nodes renderable.
99
+ */
100
+ takeSettledSubtrees(): { readonly subcalls: readonly RlmSubcall[]; readonly totals: SubcallTotals } {
101
+ const children = new Map<string | undefined, MutableSubcall[]>();
102
+ for (const sc of this.subcalls.values()) {
103
+ const siblings = children.get(sc.parentId);
104
+ if (siblings === undefined) children.set(sc.parentId, [sc]);
105
+ else siblings.push(sc);
106
+ }
107
+
108
+ // Collect a root's subtree, or undefined when any node in it is still running.
109
+ const settledSubtree = (root: MutableSubcall): MutableSubcall[] | undefined => {
110
+ const collected: MutableSubcall[] = [];
111
+ const stack: MutableSubcall[] = [root];
112
+ while (stack.length > 0) {
113
+ const node = stack.pop();
114
+ if (node === undefined) continue;
115
+ if (node.status === "running") return undefined;
116
+ collected.push(node);
117
+ const kids = children.get(node.id);
118
+ if (kids !== undefined) stack.push(...kids);
119
+ }
120
+ return collected;
121
+ };
122
+
123
+ const taken: RlmSubcall[] = [];
124
+ let costUsd = 0;
125
+ let tokens = 0;
126
+ for (const root of children.get(undefined) ?? []) {
127
+ const subtree = settledSubtree(root);
128
+ if (subtree === undefined) continue;
129
+ for (const node of subtree) {
130
+ costUsd += node.costUsd;
131
+ tokens += node.tokens;
132
+ taken.push(Object.freeze({ ...node, status: node.status as SubcallStatus }));
133
+ this.subcalls.delete(node.id);
134
+ }
135
+ }
136
+ this.totalCostUsd -= costUsd;
137
+ this.totalTokens -= tokens;
138
+ return { subcalls: taken, totals: { costUsd, tokens } };
139
+ }
140
+
85
141
  // ── Root usage (delegated from RlmEventAggregator) ──
86
142
 
87
143
  /** Accumulate root-level usage into shared totals. Called by aggregator. */
@@ -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,156 +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
- * 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.
13
- */
14
-
15
- import type { Api, Model, Usage } from "@earendil-works/pi-ai";
16
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
17
- import type { RlmEmitter } from "../tool/rlm-events.ts";
18
- import { displayModelRef, resolveModelId } from "../config/settings.ts";
19
- import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
20
- import type { Sampling } from "../core/types.ts";
21
- import { type ChatMsg, modelComplete } from "./model.ts";
22
- import { previewText } from "../text/preview.ts";
23
- import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
24
- import { mapPool } from "../util/concurrency.ts";
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
-
37
- export interface LlmBridgeOptions {
38
- /** Resolved per call so provider/config changes between calls are picked up. */
39
- readonly workerModel: () => Model<Api>;
40
- readonly registry: ModelRegistry;
41
- readonly config: () => LlmBridgeConfig;
42
- readonly signal?: AbortSignal;
43
- readonly onUsage?: (usage: Usage, model: Model<Api>) => void;
44
- /** Parent run's remaining budget/timeout; checked before every sub-call. */
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;
50
- }
51
-
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;
54
-
55
- export interface LlmBridge {
56
- llmQuery(prompt: 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`;
66
- }
67
-
68
- export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
69
- const displayModel = (model: string | null): string =>
70
- displayModelRef(opts.registry, model, opts.workerModel());
71
-
72
- // Run one completion; report cost/tokens via `track` (a per-call or per-batch accumulator).
73
- async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
74
- const config = opts.config();
75
- const rem = opts.remainingBudget?.();
76
- if (rem !== undefined) {
77
- const limitError = checkResourceLimits(rem);
78
- if (limitError !== undefined) return limitError;
79
- }
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.`);
82
- }
83
- const resolved = model ? resolveModelId(opts.registry, model) : undefined;
84
- if (model && !resolved) return formatError(`unknown model override '${model}'`);
85
- const target = resolved ?? opts.workerModel();
86
- try {
87
- const messages: ChatMsg[] = [{ role: "user", content: prompt }];
88
- const res = await modelComplete(messages, {
89
- model: target,
90
- registry: opts.registry,
91
- system: config.subSystemPrompt,
92
- maxTokens: config.subSampling?.maxTokens,
93
- temperature: config.subSampling?.temperature,
94
- reasoning: config.subSampling?.reasoning,
95
- signal: opts.signal,
96
- });
97
- opts.onUsage?.(res.usage, target);
98
- track(res.usage);
99
- return res.text;
100
- } catch (err) {
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}`);
104
- }
105
- }
106
-
107
- return {
108
- async llmQuery(prompt, model) {
109
- const emitter = opts.emitter?.();
110
- const id = emitter?.emitSubcallCreated({
111
- kind: "llm", parentId: opts.parentId?.(), label: "llm_query",
112
- model: displayModel(model), args: `prompt: ${previewText(prompt)}`,
113
- depth: opts.depth?.() ?? 0,
114
- });
115
- let cost = 0;
116
- let tokens = 0;
117
- const out = await complete1(prompt, model, (u) => {
118
- cost += u.cost.total;
119
- tokens += u.totalTokens;
120
- });
121
- if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
122
- status: isErrorText(out) ? "error" : "done",
123
- costUsd: cost, tokens, resultPreview: previewText(out),
124
- detail: isErrorText(out) ? out : undefined,
125
- });
126
- return out;
127
- },
128
-
129
- async llmQueryBatched(prompts, model) {
130
- const emitter = opts.emitter?.();
131
- const id = emitter?.emitSubcallCreated({
132
- kind: "batch", parentId: opts.parentId?.(), label: `llm_query ×${prompts.length}`,
133
- model: displayModel(model), args: `prompt: ${previewText(prompts[0] ?? "")}`,
134
- depth: opts.depth?.() ?? 0,
135
- });
136
- let cost = 0;
137
- let tokens = 0;
138
- const out = await mapPool(prompts, opts.config().maxConcurrentSubcalls, (p) =>
139
- complete1(p, model, (u) => {
140
- cost += u.cost.total;
141
- tokens += u.totalTokens;
142
- }),
143
- );
144
- const failed = out.filter(isErrorText).length;
145
- const error = batchError(failed, out.length);
146
- const firstPreview = previewText(out[0] ?? "");
147
- const resultPreview = out.length > 1 ? `${firstPreview} (+${out.length - 1} more)` : firstPreview;
148
- if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
149
- status: error ? "error" : "done", costUsd: cost, tokens,
150
- resultPreview, detail: error,
151
- failedCount: failed, totalCount: out.length,
152
- });
153
- return out;
154
- },
155
- };
156
- }
@@ -1,108 +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
- * 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.
11
- */
12
-
13
- import type { RlmResult, RunRlm } from "../core/types.ts";
14
- import type { LlmBridge } from "./llm-query.ts";
15
- import type { RlmEmitter } from "../tool/rlm-events.ts";
16
- import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
17
- import { errorMessage, formatError } from "../util/errors.ts";
18
- import { mapPool } from "../util/concurrency.ts";
19
- import { previewText } from "../text/preview.ts";
20
-
21
- /** The config slice this bridge reads; structurally satisfied by `RlmConfig`. */
22
- export interface RlmBridgeConfig {
23
- readonly maxDepth: number;
24
- readonly maxConcurrentSubcalls: number;
25
- }
26
-
27
- export interface RlmHandlers {
28
- rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
29
- rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
30
- }
31
-
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
- */
37
- readonly run: RunRlm;
38
- readonly llm: LlmBridge;
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;
46
- /** Returns the parent's remaining budget/timeout for seeding child runs. */
47
- readonly remainingBudget?: () => RemainingResources | undefined;
48
- /** Called with a child run's total cost/tokens so the parent LimitGuard debits it. */
49
- readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
50
- }
51
-
52
- export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
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> {
58
- // At the cap, a child RLM would just be an LM — short-circuit to a one-shot llm_query.
59
- if (childDepth >= opts.config().maxDepth) {
60
- return opts.llm.llmQuery(prompt, model, childDepth - 1);
61
- }
62
-
63
- const rem = opts.remainingBudget?.();
64
- if (rem !== undefined) {
65
- // Pre-spawn guard: refuse if the parent's budget or timeout is already exhausted
66
- // (reference: _subcall checks remaining_budget/timeout before spawning).
67
- const limitError = checkResourceLimits(rem);
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,
83
- depth: childDepth,
84
- parentNodeId: subId,
85
- modelOverride: model ?? undefined,
86
- remainingBudgetUsd: rem?.budgetUsd,
87
- remainingTimeoutMs: rem?.timeoutMs,
88
- });
89
- opts.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
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;
96
- } catch (err) {
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}`);
100
- }
101
- }
102
-
103
- return {
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)),
107
- };
108
- }