@hicaru/pi-rlm 0.3.6 → 0.3.8

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.
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
14
14
  import {
15
15
  isInterrupt,
16
16
  isWorkerMessage,
17
+ parsePendingTasks,
17
18
  type ParentMessage,
18
19
  type ReplResult,
19
20
  type WorkerMessage,
@@ -29,6 +30,9 @@ export type { AddContextResult, SubcallOpts, SubLlmHandlers } from "./interrupts
29
30
  export interface SandboxOptions {
30
31
  /** Sandbox recursion depth label (passed to the worker, used in interrupt routing). */
31
32
  readonly depth?: number;
33
+ /** v5 role separation: "child" sandboxes install the delegation-only scaffold (no
34
+ * search/grep_context/outline/add_context — retrieval belongs to the root). */
35
+ readonly surface?: "root" | "child";
32
36
  /** Per-`repl`-block wall-clock timeout inside the worker (seconds). */
33
37
  readonly execTimeoutS?: number;
34
38
  /** Parent-side watchdog per request (ms); on breach the worker is SIGKILLed. */
@@ -52,6 +56,8 @@ export interface SandboxOptions {
52
56
 
53
57
  const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "py", "worker.py");
54
58
  const STDERR_TAIL_CHARS = 8_192;
59
+ /** How often to refresh the parent request watchdog (and ping the worker) during silent host work. */
60
+ export const SANDBOX_WATCHDOG_HEARTBEAT_MS = 30_000;
55
61
  /** How long dispose() waits for a clean worker exit before escalating to SIGKILL. */
56
62
  const SHUTDOWN_GRACE_MS = 50;
57
63
 
@@ -118,6 +124,7 @@ export class PythonSandbox {
118
124
  "-X", "utf8=1",
119
125
  "-u", WORKER_PATH,
120
126
  "--depth", String(opts.depth ?? 1),
127
+ "--surface", opts.surface === "child" ? "child" : "root",
121
128
  "--timeout", String(opts.execTimeoutS ?? 600),
122
129
  ];
123
130
  if (opts.maxPromptChars !== undefined) {
@@ -224,6 +231,7 @@ export class PythonSandbox {
224
231
  raised: res.raised ?? false,
225
232
  executionTimeMs: Math.round((res.execution_time ?? 0) * 1000),
226
233
  varNames: res.var_names ?? [],
234
+ pendingTasks: parsePendingTasks(res.pending_tasks),
227
235
  };
228
236
  }
229
237
 
@@ -312,12 +320,22 @@ export class PythonSandbox {
312
320
  }
313
321
 
314
322
  /**
315
- * Refresh the parent-side request watchdog for every pending request.
316
- * Used during long mid-exec work that does not
317
- * produce additional worker interrupts on this sandbox.
323
+ * Refresh the parent-side request watchdog and ping the worker.
324
+ * Used during long mid-exec work that does not produce additional worker
325
+ * interrupts on this sandbox. The heartbeat rearms the worker's stall alarm
326
+ * so a healthy long sub-call is not reported as `_StallTimeout`.
318
327
  */
319
328
  refreshWatchdog(): void {
320
329
  this.touchPending();
330
+ if (this.hasPendingRequest()) this.send({ type: "heartbeat" });
331
+ }
332
+
333
+ /** True when an exec/load_context/shutdown (not the init handshake) is in flight. */
334
+ private hasPendingRequest(): boolean {
335
+ for (const id of this.pending.keys()) {
336
+ if (id !== "_init") return true;
337
+ }
338
+ return false;
321
339
  }
322
340
 
323
341
  private send(msg: ParentMessage): void {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared REPL output formatting used by headless history and native repl() tool results.
3
+ * Lives in text/ so tool/ does not import from core/.
4
+ */
5
+
6
+ import { truncateOutput } from "./parsing.ts";
7
+
8
+ /** Max stderr kept in model-visible REPL output (headless history and native tool_result). */
9
+ export const STDERR_LIMIT = 8_000;
10
+
11
+ /** Prefix stderr so the model can tell prints from exceptions. Empty when stderr is blank. */
12
+ export function formatReplStderr(stderr: string, limit = STDERR_LIMIT): string {
13
+ const err = stderr.trim();
14
+ return err ? `\n[stderr]\n${truncateOutput(err, limit)}` : "";
15
+ }
@@ -5,7 +5,9 @@
5
5
  */
6
6
 
7
7
  import type { RlmSubcall } from "./rlm-details.ts";
8
+ import type { PendingTaskInfo } from "../sandbox/protocol.ts";
8
9
  import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts";
10
+ import { formatReplStderr } from "../text/repl-output.ts";
9
11
 
10
12
  /** Model-visible text assembled from a repl() result. */
11
13
  export interface ReplResultText {
@@ -30,23 +32,20 @@ export function buildReplResultText(
30
32
  subcalls: readonly RlmSubcall[],
31
33
  backgroundPending = 0,
32
34
  varNames: readonly string[] = [],
35
+ stderr = "",
36
+ raised = false,
37
+ pendingTasks: readonly PendingTaskInfo[] = [],
33
38
  ): ReplResultText {
34
39
  const answerSubmitted = finalAnswer !== undefined;
35
- const noOutput = !answerSubmitted && !stdout;
36
- const varsHint = noOutput && varNames.length > 0
37
- ? ` — the block ran fine and these REPL vars are defined: ${varNames.join(", ")}. `
38
- + "Do NOT re-run it; read them in the next block."
39
- : "";
40
- const rawText = answerSubmitted
41
- ? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
42
- : stdout || `(no output)${varsHint}`;
40
+ const stderrBlock = formatReplStderr(stderr);
41
+ const rawText = assembleReplBody(stdout, finalAnswer, varNames, stderrBlock, raised);
43
42
  // Model-visible text is capped; the caller keeps full stdout in `details` for the TUI.
44
43
  const cappedText = capReplResultText(rawText) ?? rawText;
45
44
  const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
46
- const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
45
+ const nudge = answerSubmitted || raised ? undefined : replDelegationNudge(rawText.length, delegated);
47
46
  const failedBg = subcalls.filter((s) => s.id.startsWith("bg") && s.status === "error").length;
48
47
  const pendingLine = backgroundPending > 0
49
- ? `\n\n[rlm] ${backgroundPending} background task(s) still running — await_task(tasks) to collect.`
48
+ ? `\n\n[rlm] ${backgroundPending} background task(s) still running — ${pendingCollectHint(pendingTasks)}.`
50
49
  : "";
51
50
  const failedLine = failedBg > 0
52
51
  ? `\n[rlm] ${failedBg} background sub-call(s) FAILED — their await_task value is an "Error: …" string, not data.`
@@ -54,6 +53,51 @@ export function buildReplResultText(
54
53
  return { text: cappedText + (nudge ?? "") + pendingLine + failedLine };
55
54
  }
56
55
 
56
+ function assembleReplBody(
57
+ stdout: string,
58
+ finalAnswer: string | undefined,
59
+ varNames: readonly string[],
60
+ stderrBlock: string,
61
+ raised: boolean,
62
+ ): string {
63
+ if (finalAnswer !== undefined) {
64
+ return `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`;
65
+ }
66
+ if (raised) {
67
+ const body = [stdout.trimEnd(), stderrBlock.trimStart()].filter((s) => s.length > 0).join("\n");
68
+ return body.length > 0 ? body : "(raised — see [stderr])";
69
+ }
70
+ const noOutput = !stdout;
71
+ const varsHint = noOutput && varNames.length > 0
72
+ ? ` — the block ran fine and these REPL vars are defined: ${varNames.join(", ")}. `
73
+ + "Do NOT re-run it; read them in the next block."
74
+ : "";
75
+ return stdout || `(no output)${varsHint}`;
76
+ }
77
+
78
+ /** Name the Python handle when we know it; otherwise point at await_task() / list_tasks(). */
79
+ function pendingCollectHint(pendingTasks: readonly PendingTaskInfo[]): string {
80
+ if (pendingTasks.length === 0) {
81
+ return "collect with await_task(<Task var>) or await_task(); answers[] is for results AFTER await_task";
82
+ }
83
+ const names = new Array<string>(pendingTasks.length);
84
+ for (let i = 0; i < pendingTasks.length; i++) {
85
+ const t = pendingTasks[i];
86
+ const kind = t?.kind ?? "task";
87
+ const label = t?.label ?? "";
88
+ names[i] = t?.var ?? `<${kind}${label.length > 0 ? ` ${label}` : ""}>`;
89
+ }
90
+ if (pendingTasks.length === 1) {
91
+ const only = names[0] ?? "";
92
+ const first = pendingTasks[0];
93
+ const detail = first !== undefined && first.label.length > 0 ? ` (${first.kind} ${first.label})` : "";
94
+ return only.startsWith("<")
95
+ ? `collect with await_task()${detail}; answers[] is for results AFTER await_task`
96
+ : `collect with await_task(${only})${detail}; answers[] is for results AFTER await_task`;
97
+ }
98
+ return `collect with await_task() (${names.join(", ")}); answers[] is for results AFTER await_task`;
99
+ }
100
+
57
101
  /** Advisory diagnostics derived from a completed invocation's sub-calls. */
58
102
  export function collectReplWarnings(subcalls: readonly RlmSubcall[]): readonly string[] | undefined {
59
103
  let failed = 0;
@@ -28,6 +28,8 @@ import type { RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
28
28
  import { SandboxManager } from "../sandbox/sandbox-manager.ts";
29
29
  import type { SubcallOpts } from "../sandbox/sandbox.ts";
30
30
  import { createSubcallHandlers, type Invocation } from "../bridge/handlers/index.ts";
31
+ import { TaskLedger } from "../core/ledger.ts";
32
+ import type { MemoryStore } from "../core/memory.ts";
31
33
  import { BackgroundTasks } from "./background-tasks.ts";
32
34
  import type { ReplResult } from "../sandbox/protocol.ts";
33
35
  import { RlmEmitter } from "./rlm-events.ts";
@@ -110,8 +112,13 @@ export interface ReplToolDeps {
110
112
  readonly getConfig: () => RlmConfig;
111
113
  /** Session-wide sub-call admission, shared with every child engine this tool spawns. */
112
114
  readonly gates: SubcallGates;
115
+ /** v5: live re-resolution of the session gates (provider caps change via /rlm-config without
116
+ * a restart). Falls back to `gates` when omitted. Read lazily per sub-call. */
117
+ readonly resolveGates?: () => SubcallGates;
113
118
  /** Session-scoped home for detached spawn() work. */
114
119
  readonly background: BackgroundTasks;
120
+ /** v5 durable memory (session-wide `.rlm` store); omitted → memory off for this tool. */
121
+ readonly memory?: MemoryStore;
115
122
  readonly signal?: AbortSignal;
116
123
  readonly onUsage?: (usage: Usage, role: "sub") => void;
117
124
  readonly ensureContext?: () => Promise<void>;
@@ -127,12 +134,17 @@ export interface ReplToolDeps {
127
134
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
128
135
  const { sandboxManager, llmModel, registry, getConfig, signal, onUsage, background } = deps;
129
136
  const bridgeState = new NativeBridgeState(background);
137
+ // v5: one session-wide blackboard for the native repl() path — the same claim/coalesce/
138
+ // demote logic the engine gets per run, shared by every turn and every child it spawns.
139
+ const sessionLedger = new TaskLedger();
130
140
 
131
141
  // Late-bound cwd — getOrCreate installs handlers only at spawn; never rebuild the closure.
132
142
  let sessionCwd = process.cwd();
133
143
 
134
144
  const getLlmModel = (): Model<Api> => deps.getLlmModel?.() ?? llmModel;
135
145
  const getModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
146
+ // v5 (audit C6): resolve lazily per call so provider-cap edits via /rlm-config apply live.
147
+ const currentGates = (): SubcallGates => deps.resolveGates?.() ?? deps.gates;
136
148
 
137
149
  // Each rlm_query spawns a child RLM with its own sandbox and turn loop, not a flat
138
150
  // one-shot llm_query. The engine is created per call so the child's subcalls, turn
@@ -143,7 +155,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
143
155
  registry,
144
156
  config: getConfig(),
145
157
  signal,
146
- gates: deps.gates,
158
+ memory: deps.memory,
159
+ gates: currentGates(),
147
160
  // Same emitter the parent subcall node lives on — see SubcallHandlerDeps.runChild.
148
161
  emitter: inv.emitter,
149
162
  // Everything a child engine spends is sub-work from this tool's perspective, including
@@ -156,7 +169,10 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
156
169
  // per-invocation is reached through bridgeState.resolve, not captured here.
157
170
  const subcallHandlers = createSubcallHandlers({
158
171
  resolve: (opts) => bridgeState.resolve(opts),
159
- gates: deps.gates,
172
+ // Getter, not a captured value: sub-call deps read gates lazily per call.
173
+ get gates(): SubcallGates {
174
+ return currentGates();
175
+ },
160
176
  registry,
161
177
  getLlmModel,
162
178
  getModel,
@@ -168,6 +184,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
168
184
  // earlier repl() reaches a child spawned in a later one. Populated before any interrupt can
169
185
  // fire: execute() awaits ensureContext() before getOrCreate().
170
186
  getChildContext: () => sandboxManager.contextPayload ?? undefined,
187
+ ledger: sessionLedger,
188
+ memory: deps.memory,
171
189
  trackDetached: (task) => background.track(task),
172
190
  });
173
191
 
@@ -213,7 +231,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
213
231
  "repl: free search/outline; fire rlm_batch|map_files|llm_batch as Task (BG); await_task for results.",
214
232
  promptGuidelines: [
215
233
  "Multi-area analysis: rlm_batch([...]) or map_files(paths, q); free search; await_task — not serial native read.",
216
- "Always-spawn tools return Task; only await_task has content. Fire-all independent work before await.",
234
+ "Always-spawn tools return Task; only await_task has content. Fire-all then await; await_task() collects every still-running Task.",
217
235
  "llm_query/llm_batch have no disk — never 'Read path/to/file.ts'; use map_files or rlm_* (see context).",
218
236
  ],
219
237
  parameters: ReplToolParams,
@@ -283,6 +301,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
283
301
  await sandboxManager.getOrCreate({
284
302
  ...subcallHandlers,
285
303
  ...(contextBundle?.handlers ?? {}),
304
+ ledgerClaims: () => Promise.resolve(sessionLedger.listClaims()),
305
+ memoryOp: (op, args) => Promise.resolve(deps.memory?.serviceOp(op, args) ?? "memory off"),
286
306
  });
287
307
 
288
308
  // Detect queue contention AFTER sandbox init (initPromise settled, isExecuting now accurate)
@@ -299,12 +319,23 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
299
319
  }
300
320
 
301
321
  const start = Date.now();
302
- const result: ReplResult = await sandboxManager.execWithSetup(params.code, () => {
303
- // Wire per-invocation mutable state only after the serialized exec slot
304
- // is active. Swapping earlier would let queued repl() calls overwrite
305
- // emitter/limits for the currently running REPL execution.
306
- bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
307
- }, execSignal);
322
+ // v5 blackboard (audit C3 / R1): ancestors are the rlm_query/rlm_batch task
323
+ // strings inside this cell, not the Python soup (`print`, `await_task`). A
324
+ // child restating the user's goal then echoes. Popped in finally: detached
325
+ // work spawned by this cell already claimed at spawn time, inside this window.
326
+ const ledgerActive = getConfig().enableLedger;
327
+ const ancestorN = ledgerActive ? sessionLedger.beginNativeCell(params.code) : 0;
328
+ let result: ReplResult;
329
+ try {
330
+ result = await sandboxManager.execWithSetup(params.code, () => {
331
+ // Wire per-invocation mutable state only after the serialized exec slot
332
+ // is active. Swapping earlier would let queued repl() calls overwrite
333
+ // emitter/limits for the currently running REPL execution.
334
+ bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
335
+ }, execSignal);
336
+ } finally {
337
+ if (ledgerActive) sessionLedger.endNativeCell(ancestorN);
338
+ }
308
339
  const elapsed = Date.now() - start;
309
340
  capturedStdout = result.stdout;
310
341
  capturedStderr = result.stderr;
@@ -349,6 +380,9 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
349
380
  subcalls,
350
381
  background.pending,
351
382
  result.varNames,
383
+ result.stderr,
384
+ result.raised,
385
+ result.pendingTasks,
352
386
  );
353
387
 
354
388
  const details: ReplDetails = {
package/src/ui/status.ts CHANGED
@@ -17,7 +17,10 @@ export function formatRlmStateLine(controller: RlmController, contextUsage?: Con
17
17
  // `percent` is null right after a compaction, before the next assistant response reports usage.
18
18
  const percent = contextUsage?.percent;
19
19
  const ctxSuffix = percent === null || percent === undefined ? "" : ` · ctx ${Math.round(percent)}%`;
20
- return `● RLM ON · llm=${llm}${llmSuffix}${ctxSuffix}`;
20
+ // v5 provider caps (set via rlm.json): keep the effective admission visible.
21
+ const caps = controller.config.providerMaxConcurrent;
22
+ const capsSuffix = caps === undefined ? "" : ` · caps ${Object.entries(caps).map(([p, n]) => `${p}=${n}`).join(",")}`;
23
+ return `● RLM ON · llm=${llm}${llmSuffix}${ctxSuffix}${capsSuffix}`;
21
24
  }
22
25
 
23
26
  export function setRlmModeStatus(ui: ExtensionUIContext, controller: RlmController, contextUsage?: ContextUsage): void {
@@ -91,3 +91,50 @@ export interface SubcallGates {
91
91
  export function createSubcallGates(leafLimit: number, childLimit: number = leafLimit): SubcallGates {
92
92
  return Object.freeze({ leaf: new Semaphore(leafLimit), rlm: new DepthGates(childLimit) });
93
93
  }
94
+
95
+ /** Provider-capped config slice (RlmConfig satisfies this structurally). */
96
+ export interface ProviderCapConfig {
97
+ readonly maxConcurrentSubcalls: number;
98
+ readonly maxConcurrentChildren: number;
99
+ /** v5: per-provider concurrent-request caps, e.g. { zai: 4 }. Caps only ever LOWER a limit. */
100
+ readonly providerMaxConcurrent?: Readonly<Record<string, number>>;
101
+ }
102
+
103
+ /** min(maxConcurrentSubcalls, every involved provider's cap) — unknown providers are ignored. */
104
+ export function effectiveSubcallLimit(
105
+ config: ProviderCapConfig,
106
+ providersInUse: readonly string[],
107
+ ): number {
108
+ let limit = config.maxConcurrentSubcalls;
109
+ for (const p of providersInUse) {
110
+ const cap = config.providerMaxConcurrent?.[p];
111
+ if (cap !== undefined && cap > 0) limit = Math.min(limit, cap);
112
+ }
113
+ return limit;
114
+ }
115
+
116
+ /** Child engines are the heavy unit — cap them against their own model's provider. */
117
+ export function effectiveChildLimit(
118
+ config: ProviderCapConfig,
119
+ childModelProvider: string,
120
+ ): number {
121
+ const cap = config.providerMaxConcurrent?.[childModelProvider];
122
+ return cap !== undefined && cap > 0
123
+ ? Math.min(config.maxConcurrentChildren, cap)
124
+ : config.maxConcurrentChildren;
125
+ }
126
+
127
+ /** v5 session gates, capping the RIGHT model per gate (audit C6): leaf completions run on the
128
+ * WORKER model, recursive child engines run on the SMART model — a shared-pool fold of both
129
+ * providers would clamp the wrong side (zai smart + openai worker must cap children, not leaves).
130
+ * Pure + testable; the composition roots memoize it keyed on (config, providers). */
131
+ export function buildSessionGates(
132
+ config: ProviderCapConfig,
133
+ smartProvider: string,
134
+ workerProvider: string,
135
+ ): SubcallGates {
136
+ return createSubcallGates(
137
+ effectiveSubcallLimit(config, [workerProvider]),
138
+ effectiveChildLimit(config, smartProvider),
139
+ );
140
+ }