@hicaru/pi-rlm 0.2.1 → 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.
Files changed (61) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  6. package/src/bridge/subcall-handlers.ts +63 -17
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +6 -17
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +55 -335
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +23 -12
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -407
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +8 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/{worker.py → py/worker.py} +76 -696
  30. package/src/sandbox/sandbox-manager.ts +13 -0
  31. package/src/sandbox/sandbox.ts +99 -193
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/repl-details.ts +2 -2
  34. package/src/tool/repl-render.ts +58 -0
  35. package/src/tool/repl-result.ts +70 -0
  36. package/src/tool/repl-tool.ts +37 -159
  37. package/src/tool/rlm-aggregator.ts +2 -10
  38. package/src/tool/rlm-details.ts +0 -2
  39. package/src/tool/rlm-events.ts +0 -14
  40. package/src/tool/rlm-tool.ts +1 -12
  41. package/src/ui/config-panel.ts +4 -16
  42. package/src/ui/intro.ts +1 -2
  43. package/src/ui/model-picker.ts +34 -10
  44. package/src/ui/status.ts +3 -7
  45. package/src/util/concurrency.ts +9 -5
  46. package/src/bridge/fallback-todo.ts +0 -148
  47. package/src/bridge/interactive.ts +0 -65
  48. package/src/bridge/pi-interactive.ts +0 -41
  49. package/src/core/artifacts.ts +0 -89
  50. package/src/core/critique.ts +0 -92
  51. package/src/core/gates.ts +0 -301
  52. package/src/core/pipeline-handlers.ts +0 -319
  53. package/src/core/pipeline.ts +0 -268
  54. package/src/prompts/phases.ts +0 -104
  55. package/src/state/index.ts +0 -24
  56. package/src/state/internal.ts +0 -46
  57. package/src/state/paths.ts +0 -44
  58. package/src/state/reads.ts +0 -133
  59. package/src/state/resume.ts +0 -173
  60. package/src/state/rows.ts +0 -123
  61. package/src/state/writes.ts +0 -58
@@ -5,16 +5,10 @@
5
5
  * services `llm_query`/`rlm_query` via the bridges, and stops when the model submits an answer
6
6
  * or a limit/turn cap is hit. Recursion is wired by giving the sandbox rlm handlers that call
7
7
  * back into `runRlm` at depth+1. Used for recursion and for headless/automation runs.
8
- *
9
- * When `config.pipeline` is on at depth 0: goal capture, artifact-gated advance_phase,
10
- * history reset at phase boundaries, and measured validate→blueprint corrective routing.
11
- * The pipeline is read-only by design: it produces a validated plan; accidental
12
- * sandbox writes are blocked (steering, not a hard security boundary).
13
8
  */
14
9
 
15
10
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
16
11
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
17
- import { buildInteractiveHandlers } from "../bridge/interactive.ts";
18
12
  import { buildLibraryHandler } from "../bridge/library.ts";
19
13
  import { mergeLibraryIntoContext } from "../context/library-context.ts";
20
14
  import {
@@ -25,18 +19,9 @@ import { type ChatMsg, modelComplete } from "../bridge/model.ts";
25
19
  import { resolveModelId } from "../config/settings.ts";
26
20
  import { buildRlmSystemPrompt } from "../prompts/system.ts";
27
21
  import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
28
- import { phaseGuidance } from "../prompts/phases.ts";
29
22
  import type { RlmEmitter } from "../tool/rlm-events.ts";
30
23
  import { PythonSandbox } from "../sandbox/sandbox.ts";
31
- import {
32
- phaseGatePrompt,
33
- type Phase,
34
- type PhaseState,
35
- type StageGateData,
36
- } from "./pipeline.ts";
37
- import { PipelineController } from "./pipeline-handlers.ts";
38
- import type { ValidationGateData } from "./gates.ts";
39
- import { captureGoal, type GoalCapture } from "./artifacts.ts";
24
+ import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
40
25
  import { previewStdout, previewText } from "../text/preview.ts";
41
26
  import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
42
27
  import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
@@ -44,19 +29,7 @@ import { compactHistory, shouldCompact } from "./compaction.ts";
44
29
  import { appendUserMessage } from "./history.ts";
45
30
  import { runTurn } from "./iteration.ts";
46
31
  import { type Limits, LimitError, LimitGuard } from "./limits.ts";
47
- import type { InteractiveDeps, RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
48
- import { randomUUID } from "node:crypto";
49
- import {
50
- appendRow,
51
- appendTodoRow,
52
- generateRunId,
53
- pruneRuns,
54
- readLibrarySidecars,
55
- snapshotPath,
56
- writeContextSidecar,
57
- } from "../state/index.ts";
58
- import { STATE_SCHEMA_VERSION } from "../state/rows.ts";
59
- import type { PhaseRow, RunHeader } from "../state/rows.ts";
32
+ import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
60
33
  import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
61
34
  import { formatError } from "../util/errors.ts";
62
35
  import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
@@ -69,9 +42,9 @@ import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
69
42
  const DETACHED_SETTLE_MS = 5_000;
70
43
 
71
44
 
72
- export interface EngineDeps extends InteractiveDeps {
45
+ export interface EngineDeps {
73
46
  readonly model: Model<Api>;
74
- readonly workerModel: Model<Api>;
47
+ readonly llmModel: Model<Api>;
75
48
  readonly registry: ModelRegistry;
76
49
  readonly config: RlmConfig;
77
50
  readonly limits?: Limits;
@@ -82,74 +55,15 @@ export interface EngineDeps extends InteractiveDeps {
82
55
  readonly emitter: RlmEmitter;
83
56
  /** Called with each completion's usage (root + sub-LLM) for cost/token rollups. */
84
57
  readonly onUsage?: (usage: Usage, role: "root" | "sub") => void;
85
- /** Run-state persistence handle. undefined ⇒ persistence off. */
86
- readonly runState?: { readonly cwd: string; readonly dir: string; readonly snapshot: boolean };
87
58
  /** Test-only: override model completion (scripted multi-turn responses). */
88
59
  readonly complete?: import("./iteration.ts").CompleteFn;
89
60
  }
90
61
 
91
- /** Optional payload for a fresh-session history reset at a phase boundary. */
92
- export interface PhaseHistoryOptions {
93
- readonly goal?: GoalCapture;
94
- readonly validation?: ValidationGateData;
95
- /** Engine notice folded into the first user message (no console I/O). */
96
- readonly notice?: string;
97
- }
98
-
99
- /**
100
- * Fresh-session policy: at each phase boundary the conversation is replaced;
101
- * artifacts (paths) are the only channel. REPL variables persist — the transition
102
- * message tells the model context survives in the sandbox, not in chat.
103
- */
104
- export function resetHistoryForPhase(
105
- system: string,
106
- state: PhaseState,
107
- options: PhaseHistoryOptions = {},
108
- ): ChatMsg[] {
109
- const { goal, validation, notice } = options;
110
- const parts: string[] = [
111
- `You are entering the '${state.current}' phase.`,
112
- ];
113
- if (notice) parts.push(notice);
114
- if (goal) {
115
- parts.push(`The user's verbatim brief: read ${goal.goalPath} from the REPL (open()).`);
116
- parts.push(`Pre-run dirty baseline (exclude from delta judgment): ${goal.baselinePath}`);
117
- }
118
- for (const [p, ref] of Object.entries(state.artifacts)) {
119
- if (ref === undefined) continue;
120
- parts.push(
121
- ref.status === "superseded"
122
- ? `Superseded artifact from '${p}' (rejected by validation): ${ref.path} — read it and the validation before re-planning; do not repeat its blockers.`
123
- : `Artifact from '${p}': ${ref.path}`,
124
- );
125
- }
126
- if (validation) {
127
- parts.push(
128
- `Previous validation found ${validation.blockersCount} blocker(s) — read the validation artifact and address every blocker in the revised plan.`,
129
- );
130
- }
131
- parts.push(phaseGuidance(state.current));
132
- parts.push("Your REPL variables persist; the chat history was reset to keep your window small.");
133
- return [
134
- { role: "system", content: system },
135
- { role: "user", content: parts.join("\n") },
136
- ];
137
- }
138
-
139
62
  /** Build a `runRlm` bound to the given deps. The returned function is reused for recursion. */
140
63
  export function createEngine(deps: EngineDeps): RunRlm {
141
64
  const { emitter } = deps;
142
65
  const run: RunRlm = async (input: RlmInput): Promise<RlmResult> => {
143
- const nowIso = (): string => new Date().toISOString(); // local helper — 4 call sites below
144
- const persist = input.depth === 0 && deps.runState !== undefined;
145
- const runCwd = deps.runState?.cwd ?? process.cwd();
146
- // Compute runId early for run-state correlation on resume.
147
- const runId = persist
148
- ? (input.resume ? input.resume.header.runId : generateRunId())
149
- : undefined;
150
- // I4: session-scoped pickle trust — nonce prevents cross-session snapshot replay.
151
- // On resume, sessionNonce is undefined → no snapshots, history-only replay.
152
- const sessionNonce = persist && !input.resume ? randomUUID() : undefined;
66
+ const runCwd = process.cwd();
153
67
  // For depth > 0, input.parentNodeId is the subcall ID created by the parent's rlm-query bridge.
154
68
  // For depth 0, input.parentNodeId is undefined — engine uses root-level bridge methods.
155
69
  const selfReportId = input.depth === 0 ? undefined : input.parentNodeId;
@@ -174,15 +88,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
174
88
  const model = overrideModel ?? deps.model;
175
89
 
176
90
  // Create LimitGuard BEFORE the bridge so sub-LLM usage feeds into it.
177
- // Children inherit the parent's remaining budget/timeout (reference: limits propagate
178
- // as remaining amounts, not the full original cap).
179
- // CA: seed the clock on resume so resumed runs don't get a fresh timeout budget.
91
+ // Children inherit the parent's remaining timeout (propagated as remaining amount, not
92
+ // the full original cap).
180
93
  const limits = new LimitGuard({
181
- maxBudgetUsd: input.remainingBudgetUsd ?? deps.limits?.maxBudgetUsd,
182
94
  maxTimeoutMs: input.remainingTimeoutMs ?? deps.limits?.maxTimeoutMs,
183
95
  maxErrors: deps.limits?.maxErrors,
184
96
  maxTokens: deps.limits?.maxTokens,
185
- }, input.resume?.usageSeed.durationMs ?? 0);
97
+ });
186
98
 
187
99
  // One Invocation for the whole run: this engine owns exactly one sandbox at one depth,
188
100
  // and its emitter and LimitGuard outlive every sub-call it services — including
@@ -192,7 +104,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
192
104
  parentId: selfReportId,
193
105
  depth: input.depth,
194
106
  limits: {
195
- remainingBudgetUsd: () => limits.remainingBudgetUsd(),
196
107
  remainingTimeoutMs: () => limits.remainingTimeoutMs(),
197
108
  addUsage: (u) => {
198
109
  limits.addUsage(u);
@@ -209,13 +120,18 @@ export function createEngine(deps: EngineDeps): RunRlm {
209
120
  let detachedIdle: (() => void) | undefined;
210
121
  const subcalls = createSubcallHandlers({
211
122
  resolve: () => invocation,
212
- gates: deps.gates ?? createSubcallGates(deps.config.maxConcurrentSubcalls),
123
+ gates: deps.gates
124
+ ?? createSubcallGates(deps.config.maxConcurrentSubcalls, deps.config.maxConcurrentChildren),
213
125
  registry: deps.registry,
214
- getWorkerModel: () => deps.workerModel,
126
+ getLlmModel: () => deps.llmModel,
215
127
  getModel: () => model,
216
128
  getConfig: () => deps.config,
217
129
  signal: deps.signal,
218
130
  runChild: run,
131
+ // Read lazily: a library loaded on turn 3 must reach a child spawned on turn 4. Safe
132
+ // despite being wired before liveContext is assigned — children can only spawn from an
133
+ // interrupt during runTurn, which is strictly after loadContext below.
134
+ getChildContext: () => liveContext,
219
135
  trackDetached: async (task) => {
220
136
  detachedInFlight += 1;
221
137
  try {
@@ -236,66 +152,32 @@ export function createEngine(deps: EngineDeps): RunRlm {
236
152
  detachedIdle = undefined;
237
153
  };
238
154
  let sandbox: PythonSandbox | undefined;
155
+ /**
156
+ * This run's live context: the repo pack plus every library loaded so far. Children inherit
157
+ * it, so it must grow when load_library appends (see the library handler's onLoaded below).
158
+ *
159
+ * Run-scoped on purpose — recursion means N of these are live at once, and a module-level
160
+ * "current context" would hand a depth-3 child its cousin's world.
161
+ */
162
+ let liveContext: unknown = null;
163
+ /**
164
+ * This run's hold on the serialized context file. Kept for the whole run so every child that
165
+ * inherits the same payload reuses one file instead of re-serializing the repository.
166
+ */
167
+ let contextPin: PinnedContext | undefined;
168
+ /** Re-pin after the payload changes identity (mergeLibraryIntoContext returns a new array). */
169
+ const repinLiveContext = async (): Promise<void> => {
170
+ const previous = contextPin;
171
+ contextPin = await pinContext(liveContext);
172
+ await previous?.release();
173
+ };
239
174
  let best = "";
240
175
  let lastAnswer = "";
241
176
  let compactions = 0;
242
177
  let completedTurns = 0;
243
- /** Owns all pipeline state (phase, per-phase latest save, ask rounds, pending reset). */
244
- let pipeline: PipelineController | undefined;
245
178
  let nodeStatus: "done" | "error" = "done";
246
- let persistOn = persist;
247
- if (persist && deps.runState && !input.resume && runId) {
248
- const json = typeof input.context !== "string";
249
- const sidecarOk = await writeContextSidecar(deps.runState.cwd, deps.runState.dir, runId, input.context, json);
250
- if (!sidecarOk) {
251
- persistOn = false; // QC: skip header if sidecar failed — prevents orphan trail referencing non-existent context
252
- } else {
253
- const header: RunHeader = {
254
- kind: "header", v: STATE_SCHEMA_VERSION, runId, ts: nowIso(),
255
- rootPrompt: input.rootPrompt,
256
- context: { type: contextTypeLabel(input.context), chars: contextLength(input.context), json },
257
- models: { model: model.id, worker: deps.workerModel.id },
258
- meta: { maxIterations: deps.config.maxIterations, maxDepth: deps.config.maxDepth, orchestrator: deps.config.orchestrator, pipeline: deps.config.pipeline },
259
- };
260
- persistOn = await appendRow(deps.runState.cwd, deps.runState.dir, runId, header);
261
- }
262
- await pruneRuns(deps.runState.cwd, deps.runState.dir, deps.config.runLog?.maxRuns ?? 50); // Ops: retention (always — cleanup even if sidecar failed)
263
- }
264
-
265
- const recordTerminal = async (status: "completed" | "finalized" | "aborted" | "stopped", r: RlmResult): Promise<boolean> => {
266
- if (!persistOn || !runId || !deps.runState) return false;
267
- return await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
268
- kind: "terminal", ts: nowIso(), status, answer: r.answer, iterations: r.iterations,
269
- usage: { costUsd: r.costUsd, inputTokens: r.inputTokens, outputTokens: r.outputTokens },
270
- });
271
- };
272
-
273
- const persistPhaseRow = async (
274
- state: PhaseState,
275
- artifactPath: string | undefined,
276
- artifactPhase: Phase | undefined,
277
- gateData: StageGateData | undefined,
278
- supersededPath?: string,
279
- ): Promise<void> => {
280
- if (!persistOn || !runId || !deps.runState) return;
281
- const row: PhaseRow = {
282
- kind: "phase",
283
- turn: completedTurns + 1,
284
- ts: nowIso(),
285
- phase: state.current,
286
- summary: state.summary,
287
- artifactPath,
288
- artifactPhase,
289
- blockersCount: gateData?.kind === "validation" ? gateData.validation.blockersCount : undefined,
290
- backwardJumps: state.backwardJumps,
291
- supersededPath,
292
- };
293
- const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, row);
294
- if (!ok) persistOn = false;
295
- };
296
179
 
297
180
  try {
298
- const pipelineOn = input.depth === 0 && deps.config.pipeline;
299
181
  const meta = {
300
182
  contextType: contextTypeLabel(input.context),
301
183
  contextChars: contextLength(input.context),
@@ -305,74 +187,23 @@ export function createEngine(deps: EngineDeps): RunRlm {
305
187
  const system = buildRlmSystemPrompt(meta, {
306
188
  orchestrator: deps.config.orchestrator,
307
189
  recursion: input.depth + 1 < deps.config.maxDepth,
308
- askUserQuestion: deps.config.askUserQuestion && input.depth === 0,
309
- todo: deps.config.todo,
310
- pipeline: deps.config.pipeline && input.depth === 0,
311
190
  maxPromptChars: deps.config.maxPromptChars,
312
191
  libraryLoader: deps.config.libraryLoader,
192
+ child: input.depth > 0,
313
193
  });
314
194
 
315
- pipeline = new PipelineController({
316
- runCwd,
317
- maxBackwardJumps: deps.config.maxBackwardJumps,
318
- emitter,
319
- completedTurns: () => completedTurns,
320
- resetHistoryForPhase: (state, options) => resetHistoryForPhase(system, state, options),
321
- persistPhaseRow,
322
- });
323
- const phaseHandlers = pipelineOn ? pipeline.handlers() : {};
324
- const baseAsk = deps.config.askUserQuestion ? deps.onAskUserQuestion : undefined;
325
- const interactiveHandlers = buildInteractiveHandlers({
326
- onAskUserQuestion: baseAsk
327
- ? async (questions) => {
328
- const answers = await baseAsk(questions);
329
- // Count only successfully serviced root-depth rounds (handler already rejects depth>0).
330
- pipeline?.noteAskRound();
331
- return answers;
332
- }
333
- : undefined,
334
- onTodo: deps.config.todo ? deps.onTodo : undefined,
335
- onTodoRow: async (action, params, todoResult) => {
336
- if (!persistOn || !runId || !deps.runState) return;
337
- const ok = await appendTodoRow(deps.runState.cwd, deps.runState.dir, runId, {
338
- turn: completedTurns + 1, ts: nowIso(), action, params, result: todoResult,
339
- });
340
- if (!ok) persistOn = false;
341
- },
342
- emitter,
343
- depth: input.depth,
344
- parentId: selfReportId,
345
- });
346
-
347
- const restoredSlots = input.resume && deps.runState && runId
348
- ? await readLibrarySidecars(deps.runState.cwd, deps.runState.dir, runId)
349
- : [];
350
- // Seed host-side idempotency from restored sidecars so re-load is a no-op.
351
- const restoredPrefixes: string[] = [];
352
- for (const slot of restoredSlots) {
353
- if (!Array.isArray(slot.payload) || slot.payload.length === 0) continue;
354
- const first = slot.payload[0];
355
- if (first === null || typeof first !== "object") continue;
356
- const path = typeof (first as { path?: unknown }).path === "string"
357
- ? (first as { path: string }).path
358
- : "";
359
- const m = path.match(/^(lib\/[^/]+\/)/);
360
- if (m?.[1] !== undefined) restoredPrefixes.push(m[1]);
361
- }
362
195
  const libraryHandlers = deps.config.libraryLoader
363
196
  ? buildLibraryHandler({
364
197
  cwd: runCwd,
365
198
  emitter,
366
199
  parentId: selfReportId,
367
200
  signal: deps.signal,
368
- startIndex: 1 + restoredSlots.reduce((m, s) => Math.max(m, s.index), 0),
369
- loadedPrefixes: restoredPrefixes,
370
- onLoaded: async (index, payload) => {
371
- if (!persistOn || !runId || !deps.runState) return;
372
- await writeContextSidecar(
373
- deps.runState.cwd, deps.runState.dir, runId,
374
- payload, typeof payload !== "string", index,
375
- );
201
+ getContext: () => liveContext,
202
+ onLoaded: async (payload) => {
203
+ // The accumulator is what children inherit, which is what makes inheritance
204
+ // transitive grandchildren see the library too.
205
+ liveContext = mergeLibraryIntoContext(liveContext, payload);
206
+ await repinLiveContext();
376
207
  },
377
208
  }).handlers
378
209
  : {};
@@ -386,85 +217,36 @@ export function createEngine(deps: EngineDeps): RunRlm {
386
217
  initTimeoutMs: deps.config.sandboxInitTimeoutMs,
387
218
  maxPromptChars: deps.config.maxPromptChars,
388
219
  awaitTimeoutS: Math.round(deps.config.requestTimeoutMs / 1000),
389
- // Pipeline at depth 0 is read-only: guard open() write modes in the worker.
390
- readOnly: pipelineOn,
391
- handlers: { ...subcalls, ...phaseHandlers, ...interactiveHandlers, ...libraryHandlers },
220
+ handlers: { ...subcalls, ...libraryHandlers },
392
221
  });
393
222
 
394
- let history: ChatMsg[] = input.resume ? input.resume.history : [{ role: "system", content: system }];
395
- let pendingReplOutputs: string | undefined = input.resume?.pendingReplOutputs;
396
- const startTurn = input.resume?.completedTurns ?? 0;
397
- if (input.resume) {
398
- limits.addRaw(input.resume.usageSeed.costUsd, input.resume.usageSeed.inputTokens, input.resume.usageSeed.outputTokens);
399
- best = input.resume.best;
400
- compactions = input.resume.compactions;
401
- completedTurns = input.resume.completedTurns;
402
- if (input.resume.phase) pipeline.seedFromResume(input.resume.phase);
403
- } else if (pipelineOn) {
404
- // Goal capture (script, no LLM) + seed phase state + fresh history.
405
- const captured = captureGoal(runCwd, input.rootPrompt);
406
- // Fail-soft: fold the failure into the first reset message (never console — corrupts TUI).
407
- const goalNotice = captured.ok
408
- ? undefined
409
- : `Note: goal artifact could not be written (${captured.error}); the brief remains only in the system prompt.`;
410
- // Clarify only when interviews are enabled AND the host wired a callback.
411
- // Config alone is not enough: without onAskUserQuestion every ask throws and
412
- // the run would burn maxIterations stuck at clarify (askRounds stays 0).
413
- const startPhase =
414
- deps.config.askUserQuestion && deps.onAskUserQuestion !== undefined
415
- ? "clarify"
416
- : "research";
417
- history = pipeline.seedFresh(startPhase, captured.ok ? captured.value : undefined, goalNotice);
418
- }
223
+ let history: ChatMsg[] = [{ role: "system", content: system }];
224
+ let pendingReplOutputs: string | undefined;
419
225
 
420
226
  // Context: serialize ContextBundle to sandbox-ready JSON array, pass raw strings through.
421
- // Resume: merge library sidecars into the single `context` list (no context_N slots).
422
- let contextValue: unknown =
227
+ liveContext =
423
228
  typeof input.context === "object" && input.context !== null && "files" in input.context
424
229
  ? serializeForSandbox(input.context as ContextBundle)
425
230
  : input.context;
426
- for (const slot of restoredSlots) {
427
- contextValue = mergeLibraryIntoContext(contextValue, slot.payload);
428
- }
429
- await sandbox.loadContext(contextValue);
430
- if (input.resume?.snapshotTurn !== undefined && deps.runState && runId && sessionNonce) // R-C1: restore only for same-session (sessionNonce present)
431
- await sandbox.restore(snapshotPath(deps.runState.cwd, deps.runState.dir, runId, input.resume.snapshotTurn), sessionNonce);
432
- for (let i = startTurn; i < deps.config.maxIterations; i++) {
231
+ contextPin = await pinContext(liveContext);
232
+ await sandbox.loadContextPinned(contextPin);
233
+ for (let i = 0; i < deps.config.maxIterations; i++) {
433
234
  limits.checkTimeout();
434
235
  if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
435
236
  else emitter.emitTurn(i + 1, deps.config.maxIterations);
436
237
 
437
- // Apply deferred history reset from a prior advance_phase (fresh session policy).
438
- const scheduledReset = pipeline.takePendingReset();
439
- if (scheduledReset !== undefined) {
440
- history = scheduledReset;
441
- pendingReplOutputs = undefined;
442
- }
443
-
444
238
  if (deps.config.compaction) {
445
239
  const compactionDeps = {
446
240
  // Summarisation is done by the cheap worker model; the threshold stays on the
447
241
  // root model's context window (that is the window the history fills each turn).
448
- model: deps.workerModel,
242
+ model: deps.llmModel,
449
243
  registry: deps.registry,
450
244
  contextWindow: model.contextWindow,
451
245
  thresholdPct: deps.config.compactionThresholdPct,
452
246
  signal: deps.signal,
453
247
  };
454
248
  if (shouldCompact(history, compactionDeps)) {
455
- const prevHistoryRef = history;
456
- let compactionUsage = { costUsd: 0, inputTokens: 0, outputTokens: 0 };
457
- history = await compactHistory(history, compactionDeps, ++compactions, (u) => {
458
- limits.addUsage(u);
459
- compactionUsage = { costUsd: compactionUsage.costUsd + u.cost.total, inputTokens: compactionUsage.inputTokens + u.input, outputTokens: compactionUsage.outputTokens + u.output }; // CC: accumulate
460
- });
461
- if (persistOn && runId && deps.runState && history !== prevHistoryRef) {
462
- const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
463
- kind: "compaction", turn: i + 1, ts: nowIso(), history,
464
- usage: compactionUsage,
465
- });
466
- if (!ok) persistOn = false; // QC: disable persistence on first failure (match turn-row pattern)
467
- }
249
+ history = await compactHistory(history, compactionDeps, ++compactions, (u) => limits.addUsage(u));
468
250
  }
469
251
  }
470
252
 
@@ -473,14 +255,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
473
255
  pendingReplOutputs = undefined;
474
256
  }
475
257
 
476
- const gateMsg = deps.config.pipeline ? phaseGatePrompt(pipeline.phase, completedTurns) : undefined;
477
- const gateUserMsg = gateMsg ? `[${new Date().toISOString()}] ${gateMsg}` : undefined;
478
- // Phase guidance lives only in resetHistoryForPhase (fresh session) — do not re-inject
479
- // into every turn prompt (avoids duplication on turn 1 and dead post-transition flags).
480
- appendUserMessage(
481
- history,
482
- buildTurnPrompt(i, deps.config.maxIterations, gateUserMsg),
483
- );
258
+ appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations));
484
259
 
485
260
  // rootSampling fields win; smartReasoning is the default reasoning when not overridden.
486
261
  const rootSampling: Sampling = {
@@ -510,86 +285,29 @@ export function createEngine(deps: EngineDeps): RunRlm {
510
285
  completedTurns = i + 1;
511
286
  const final = finalAnswerOf(turn.results);
512
287
  if (final != null) {
513
- // Validate-phase finalize is gated: the controller measures THIS turn's validation
514
- // save only, never phase.artifacts (stale after a prior corrective loop).
515
- if (pipelineOn && pipeline.phase?.current === "validate") {
516
- const outcome = await pipeline.finalizeInValidate(final);
517
- if (outcome.kind === "reject") {
518
- history.push({ role: "assistant", content: turn.response });
519
- pendingReplOutputs = outcome.error;
520
- continue;
521
- }
522
- if (outcome.kind === "loop-back") {
523
- history = outcome.history;
524
- pendingReplOutputs = undefined;
525
- continue;
526
- }
527
- if (outcome.kind === "halt") {
528
- const halted = result(outcome.report, i + 1, limits);
529
- await recordTerminal("completed", halted);
530
- lastAnswer = halted.answer;
531
- return halted;
532
- }
533
- // outcome.kind === "accept" — take the model's final answer
534
- }
535
288
  const done = result(final, i + 1, limits);
536
- await recordTerminal("completed", done);
537
289
  lastAnswer = done.answer;
538
290
  return done;
539
291
  }
540
292
 
541
293
  limits.observe(turnHadError(turn.results));
542
- // Always capture this turn's REPL outputs for the JSONL trail (fidelity).
543
- const turnReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
544
- // If advance_phase scheduled a history reset, apply it now (do not pollute fresh history).
545
- const nextReset = pipeline.takePendingReset();
546
- if (nextReset !== undefined) {
547
- history = nextReset;
548
- // Fanout/advance result is already embedded in the reset user message — do not
549
- // also append raw REPL stdout as a next-turn user message.
550
- pendingReplOutputs = undefined;
551
- } else {
552
- history.push({ role: "assistant", content: turn.response });
553
- pendingReplOutputs = turnReplOutputs;
554
- }
555
-
556
- if (persistOn && runId && deps.runState) {
557
- const pklPath = snapshotPath(deps.runState.cwd, deps.runState.dir, runId, i + 1);
558
- const snapOk = deps.runState.snapshot && sandbox && sessionNonce
559
- ? await sandbox.snapshot(pklPath, sessionNonce)
560
- : false;
561
- const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
562
- kind: "turn", turn: i + 1, ts: nowIso(),
563
- response: turn.response,
564
- // Trail keeps the real REPL output even when history was reset (issue #9).
565
- replOutputs: turnReplOutputs || undefined,
566
- answerContent: answerContent || undefined,
567
- error: turnHadError(turn.results),
568
- usage: { costUsd: turn.usage.cost.total, inputTokens: turn.usage.input, outputTokens: turn.usage.output }, // B2: Usage has .input/.output, not .inputTokens/.outputTokens
569
- cumulativeDurationMs: limits.usage().durationMs, // B3: required by TurnRow, seeds LimitGuard clock on resume (CA)
570
- snapshotOk: snapOk,
571
- });
572
- if (!ok) persistOn = false;
573
- // No finalizeSnapshot — snapshot is atomic (os.rename inside worker.py)
574
- }
294
+ history.push({ role: "assistant", content: turn.response });
295
+ pendingReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
575
296
  }
576
297
  if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
577
298
  const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
578
- await recordTerminal("finalized", finalized);
579
299
  lastAnswer = finalized.answer;
580
300
  return finalized;
581
301
  } catch (err) {
582
302
  // Abort is a user action — resolve with the best partial, not an error.
583
303
  if (deps.signal?.aborted) {
584
304
  const aborted = result(best.trim() || "(aborted)", completedTurns, limits);
585
- await recordTerminal("aborted", aborted);
586
305
  lastAnswer = aborted.answer;
587
306
  return aborted;
588
307
  }
589
308
  if (err instanceof LimitError) {
590
309
  nodeStatus = "error";
591
310
  const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits);
592
- await recordTerminal("stopped", stopped);
593
311
  lastAnswer = stopped.answer;
594
312
  return stopped;
595
313
  }
@@ -607,7 +325,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
607
325
  if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
608
326
  emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
609
327
  }
328
+ // Settle detached work FIRST: a child still running may be about to pin this same payload.
610
329
  await settleDetached();
330
+ await contextPin?.release();
611
331
  await sandbox?.dispose();
612
332
  }
613
333
  };
@@ -1,4 +1,4 @@
1
- /** Shared history mutation helpers used by both the engine loop and the resume fold. */
1
+ /** Shared history mutation helpers for the engine loop. */
2
2
 
3
3
  import type { ChatMsg } from "../bridge/model.ts";
4
4
 
@@ -1,7 +1,9 @@
1
1
  /**
2
- * LimitGuard — wall-clock, token, cost, and consecutive-error caps for a headless RLM run
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" | "budget" | "errors",
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, maxBudgetUsd } = this.limits;
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
  }