@hicaru/pi-rlm 0.2.1 → 0.3.0

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 (79) hide show
  1. package/README.md +28 -47
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +22 -19
  5. package/src/bridge/add-context.ts +322 -0
  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 +8 -18
  10. package/src/config/settings.ts +13 -34
  11. package/src/context/anydoc.ts +67 -0
  12. package/src/context/listing.ts +70 -0
  13. package/src/context/md-cache.ts +112 -0
  14. package/src/context/merge.ts +97 -0
  15. package/src/context/namespace.ts +180 -0
  16. package/src/context/resolve.ts +122 -0
  17. package/src/context/source-dir.ts +166 -0
  18. package/src/context/source-doc.ts +71 -0
  19. package/src/context/source-git.ts +51 -0
  20. package/src/context/source-text.ts +45 -0
  21. package/src/context/types.ts +88 -0
  22. package/src/context/walk.ts +250 -0
  23. package/src/core/engine.ts +61 -345
  24. package/src/core/history.ts +1 -1
  25. package/src/core/limits.ts +5 -12
  26. package/src/core/resource-limits.ts +0 -2
  27. package/src/core/types.ts +10 -38
  28. package/src/index.ts +92 -54
  29. package/src/mode/llm-model.ts +54 -0
  30. package/src/mode/rlm-mode.ts +28 -58
  31. package/src/prompts/glossary.ts +290 -0
  32. package/src/prompts/native.ts +127 -0
  33. package/src/prompts/system.ts +15 -408
  34. package/src/sandbox/context-file.ts +154 -0
  35. package/src/sandbox/interrupts.ts +160 -0
  36. package/src/sandbox/protocol.ts +20 -75
  37. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  38. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  39. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  40. package/src/sandbox/py/guards.py +150 -0
  41. package/src/sandbox/py/retrieval.py +265 -0
  42. package/src/sandbox/py/tasks.py +129 -0
  43. package/src/sandbox/py/worker.py +856 -0
  44. package/src/sandbox/sandbox-manager.ts +24 -9
  45. package/src/sandbox/sandbox.ts +99 -193
  46. package/src/text/tokens.ts +31 -5
  47. package/src/tool/repl-details.ts +2 -2
  48. package/src/tool/repl-render.ts +58 -0
  49. package/src/tool/repl-result.ts +70 -0
  50. package/src/tool/repl-tool.ts +60 -170
  51. package/src/tool/rlm-aggregator.ts +2 -10
  52. package/src/tool/rlm-details.ts +0 -2
  53. package/src/tool/rlm-events.ts +0 -14
  54. package/src/tool/rlm-tool.ts +2 -13
  55. package/src/ui/config-panel.ts +12 -20
  56. package/src/ui/intro.ts +1 -2
  57. package/src/ui/model-picker.ts +34 -10
  58. package/src/ui/status.ts +3 -7
  59. package/src/util/concurrency.ts +9 -5
  60. package/src/bridge/fallback-todo.ts +0 -148
  61. package/src/bridge/interactive.ts +0 -65
  62. package/src/bridge/library.ts +0 -155
  63. package/src/bridge/pi-interactive.ts +0 -41
  64. package/src/context/library-context.ts +0 -266
  65. package/src/context/repomix-context.ts +0 -204
  66. package/src/core/artifacts.ts +0 -89
  67. package/src/core/critique.ts +0 -92
  68. package/src/core/gates.ts +0 -301
  69. package/src/core/pipeline-handlers.ts +0 -319
  70. package/src/core/pipeline.ts +0 -268
  71. package/src/prompts/phases.ts +0 -104
  72. package/src/sandbox/worker.py +0 -1456
  73. package/src/state/index.ts +0 -24
  74. package/src/state/internal.ts +0 -46
  75. package/src/state/paths.ts +0 -44
  76. package/src/state/reads.ts +0 -133
  77. package/src/state/resume.ts +0 -173
  78. package/src/state/rows.ts +0 -123
  79. package/src/state/writes.ts +0 -58
@@ -5,18 +5,12 @@
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
- import { buildLibraryHandler } from "../bridge/library.ts";
19
- import { mergeLibraryIntoContext } from "../context/library-context.ts";
12
+ import { buildAddContextHandler } from "../bridge/add-context.ts";
13
+ import { mergeIntoContext } from "../context/merge.ts";
20
14
  import {
21
15
  createSubcallHandlers,
22
16
  type Invocation,
@@ -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,20 +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";
60
- import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
32
+ import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
61
33
  import { formatError } from "../util/errors.ts";
62
34
  import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
63
35
 
@@ -69,9 +41,9 @@ import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
69
41
  const DETACHED_SETTLE_MS = 5_000;
70
42
 
71
43
 
72
- export interface EngineDeps extends InteractiveDeps {
44
+ export interface EngineDeps {
73
45
  readonly model: Model<Api>;
74
- readonly workerModel: Model<Api>;
46
+ readonly llmModel: Model<Api>;
75
47
  readonly registry: ModelRegistry;
76
48
  readonly config: RlmConfig;
77
49
  readonly limits?: Limits;
@@ -82,74 +54,15 @@ export interface EngineDeps extends InteractiveDeps {
82
54
  readonly emitter: RlmEmitter;
83
55
  /** Called with each completion's usage (root + sub-LLM) for cost/token rollups. */
84
56
  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
57
  /** Test-only: override model completion (scripted multi-turn responses). */
88
58
  readonly complete?: import("./iteration.ts").CompleteFn;
89
59
  }
90
60
 
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
61
  /** Build a `runRlm` bound to the given deps. The returned function is reused for recursion. */
140
62
  export function createEngine(deps: EngineDeps): RunRlm {
141
63
  const { emitter } = deps;
142
64
  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;
65
+ const runCwd = process.cwd();
153
66
  // For depth > 0, input.parentNodeId is the subcall ID created by the parent's rlm-query bridge.
154
67
  // For depth 0, input.parentNodeId is undefined — engine uses root-level bridge methods.
155
68
  const selfReportId = input.depth === 0 ? undefined : input.parentNodeId;
@@ -174,15 +87,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
174
87
  const model = overrideModel ?? deps.model;
175
88
 
176
89
  // 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.
90
+ // Children inherit the parent's remaining timeout (propagated as remaining amount, not
91
+ // the full original cap).
180
92
  const limits = new LimitGuard({
181
- maxBudgetUsd: input.remainingBudgetUsd ?? deps.limits?.maxBudgetUsd,
182
93
  maxTimeoutMs: input.remainingTimeoutMs ?? deps.limits?.maxTimeoutMs,
183
94
  maxErrors: deps.limits?.maxErrors,
184
95
  maxTokens: deps.limits?.maxTokens,
185
- }, input.resume?.usageSeed.durationMs ?? 0);
96
+ });
186
97
 
187
98
  // One Invocation for the whole run: this engine owns exactly one sandbox at one depth,
188
99
  // and its emitter and LimitGuard outlive every sub-call it services — including
@@ -192,7 +103,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
192
103
  parentId: selfReportId,
193
104
  depth: input.depth,
194
105
  limits: {
195
- remainingBudgetUsd: () => limits.remainingBudgetUsd(),
196
106
  remainingTimeoutMs: () => limits.remainingTimeoutMs(),
197
107
  addUsage: (u) => {
198
108
  limits.addUsage(u);
@@ -209,13 +119,18 @@ export function createEngine(deps: EngineDeps): RunRlm {
209
119
  let detachedIdle: (() => void) | undefined;
210
120
  const subcalls = createSubcallHandlers({
211
121
  resolve: () => invocation,
212
- gates: deps.gates ?? createSubcallGates(deps.config.maxConcurrentSubcalls),
122
+ gates: deps.gates
123
+ ?? createSubcallGates(deps.config.maxConcurrentSubcalls, deps.config.maxConcurrentChildren),
213
124
  registry: deps.registry,
214
- getWorkerModel: () => deps.workerModel,
125
+ getLlmModel: () => deps.llmModel,
215
126
  getModel: () => model,
216
127
  getConfig: () => deps.config,
217
128
  signal: deps.signal,
218
129
  runChild: run,
130
+ // Read lazily: a source added on turn 3 must reach a child spawned on turn 4. Safe
131
+ // despite being wired before liveContext is assigned — children can only spawn from an
132
+ // interrupt during runTurn, which is strictly after loadContext below.
133
+ getChildContext: () => liveContext,
219
134
  trackDetached: async (task) => {
220
135
  detachedInFlight += 1;
221
136
  try {
@@ -236,66 +151,32 @@ export function createEngine(deps: EngineDeps): RunRlm {
236
151
  detachedIdle = undefined;
237
152
  };
238
153
  let sandbox: PythonSandbox | undefined;
154
+ /**
155
+ * This run's live context: whatever was seeded plus every source added so far. Children
156
+ * inherit it, so it must grow when add_context appends (see the handler's onLoaded below).
157
+ *
158
+ * Run-scoped on purpose — recursion means N of these are live at once, and a module-level
159
+ * "current context" would hand a depth-3 child its cousin's world.
160
+ */
161
+ let liveContext: unknown = [];
162
+ /**
163
+ * This run's hold on the serialized context file. Kept for the whole run so every child that
164
+ * inherits the same payload reuses one file instead of re-serializing the repository.
165
+ */
166
+ let contextPin: PinnedContext | undefined;
167
+ /** Re-pin after the payload changes identity (mergeIntoContext returns a new array). */
168
+ const repinLiveContext = async (): Promise<void> => {
169
+ const previous = contextPin;
170
+ contextPin = await pinContext(liveContext);
171
+ await previous?.release();
172
+ };
239
173
  let best = "";
240
174
  let lastAnswer = "";
241
175
  let compactions = 0;
242
176
  let completedTurns = 0;
243
- /** Owns all pipeline state (phase, per-phase latest save, ask rounds, pending reset). */
244
- let pipeline: PipelineController | undefined;
245
177
  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
178
 
297
179
  try {
298
- const pipelineOn = input.depth === 0 && deps.config.pipeline;
299
180
  const meta = {
300
181
  contextType: contextTypeLabel(input.context),
301
182
  contextChars: contextLength(input.context),
@@ -305,74 +186,23 @@ export function createEngine(deps: EngineDeps): RunRlm {
305
186
  const system = buildRlmSystemPrompt(meta, {
306
187
  orchestrator: deps.config.orchestrator,
307
188
  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
189
  maxPromptChars: deps.config.maxPromptChars,
312
- libraryLoader: deps.config.libraryLoader,
190
+ contextLoader: deps.config.contextLoader,
191
+ child: input.depth > 0,
313
192
  });
314
193
 
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
- const libraryHandlers = deps.config.libraryLoader
363
- ? buildLibraryHandler({
194
+ const contextHandlers = deps.config.contextLoader
195
+ ? buildAddContextHandler({
364
196
  cwd: runCwd,
365
197
  emitter,
366
198
  parentId: selfReportId,
367
199
  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
- );
200
+ getContext: () => liveContext,
201
+ onLoaded: async (payload) => {
202
+ // The accumulator is what children inherit, which is what makes inheritance
203
+ // transitive grandchildren see the source too.
204
+ liveContext = mergeIntoContext(liveContext, payload);
205
+ await repinLiveContext();
376
206
  },
377
207
  }).handlers
378
208
  : {};
@@ -386,85 +216,33 @@ export function createEngine(deps: EngineDeps): RunRlm {
386
216
  initTimeoutMs: deps.config.sandboxInitTimeoutMs,
387
217
  maxPromptChars: deps.config.maxPromptChars,
388
218
  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 },
219
+ handlers: { ...subcalls, ...contextHandlers },
392
220
  });
393
221
 
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
- }
222
+ let history: ChatMsg[] = [{ role: "system", content: system }];
223
+ let pendingReplOutputs: string | undefined;
419
224
 
420
- // 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 =
423
- typeof input.context === "object" && input.context !== null && "files" in input.context
424
- ? serializeForSandbox(input.context as ContextBundle)
425
- : 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++) {
225
+ // Context is already a sandbox-ready list (or a raw string for text children).
226
+ liveContext = input.context ?? [];
227
+ contextPin = await pinContext(liveContext);
228
+ await sandbox.loadContextPinned(contextPin);
229
+ for (let i = 0; i < deps.config.maxIterations; i++) {
433
230
  limits.checkTimeout();
434
231
  if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
435
232
  else emitter.emitTurn(i + 1, deps.config.maxIterations);
436
233
 
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
234
  if (deps.config.compaction) {
445
235
  const compactionDeps = {
446
236
  // Summarisation is done by the cheap worker model; the threshold stays on the
447
237
  // root model's context window (that is the window the history fills each turn).
448
- model: deps.workerModel,
238
+ model: deps.llmModel,
449
239
  registry: deps.registry,
450
240
  contextWindow: model.contextWindow,
451
241
  thresholdPct: deps.config.compactionThresholdPct,
452
242
  signal: deps.signal,
453
243
  };
454
244
  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
- }
245
+ history = await compactHistory(history, compactionDeps, ++compactions, (u) => limits.addUsage(u));
468
246
  }
469
247
  }
470
248
 
@@ -473,14 +251,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
473
251
  pendingReplOutputs = undefined;
474
252
  }
475
253
 
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
- );
254
+ appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations));
484
255
 
485
256
  // rootSampling fields win; smartReasoning is the default reasoning when not overridden.
486
257
  const rootSampling: Sampling = {
@@ -510,86 +281,29 @@ export function createEngine(deps: EngineDeps): RunRlm {
510
281
  completedTurns = i + 1;
511
282
  const final = finalAnswerOf(turn.results);
512
283
  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
284
  const done = result(final, i + 1, limits);
536
- await recordTerminal("completed", done);
537
285
  lastAnswer = done.answer;
538
286
  return done;
539
287
  }
540
288
 
541
289
  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
- }
290
+ history.push({ role: "assistant", content: turn.response });
291
+ pendingReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
575
292
  }
576
293
  if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
577
294
  const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
578
- await recordTerminal("finalized", finalized);
579
295
  lastAnswer = finalized.answer;
580
296
  return finalized;
581
297
  } catch (err) {
582
298
  // Abort is a user action — resolve with the best partial, not an error.
583
299
  if (deps.signal?.aborted) {
584
300
  const aborted = result(best.trim() || "(aborted)", completedTurns, limits);
585
- await recordTerminal("aborted", aborted);
586
301
  lastAnswer = aborted.answer;
587
302
  return aborted;
588
303
  }
589
304
  if (err instanceof LimitError) {
590
305
  nodeStatus = "error";
591
306
  const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits);
592
- await recordTerminal("stopped", stopped);
593
307
  lastAnswer = stopped.answer;
594
308
  return stopped;
595
309
  }
@@ -607,7 +321,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
607
321
  if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
608
322
  emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
609
323
  }
324
+ // Settle detached work FIRST: a child still running may be about to pin this same payload.
610
325
  await settleDetached();
326
+ await contextPin?.release();
611
327
  await sandbox?.dispose();
612
328
  }
613
329
  };
@@ -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
  }