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