@hicaru/pi-rlm 0.1.8 → 0.2.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.
- package/README.md +22 -19
- package/package.json +2 -1
- package/src/bridge/library.ts +93 -15
- package/src/bridge/llm-query.ts +60 -36
- package/src/bridge/rlm-query.ts +63 -79
- package/src/commands/rlm-config.ts +8 -8
- package/src/commands/rlm.ts +48 -12
- package/src/config/settings.ts +33 -3
- package/src/context/library-context.ts +209 -22
- package/src/context/repomix-context.ts +7 -58
- package/src/core/answer.ts +5 -13
- package/src/core/artifacts.ts +4 -3
- package/src/core/critique.ts +92 -0
- package/src/core/engine.ts +94 -299
- package/src/core/gates.ts +33 -4
- package/src/core/limits.ts +19 -1
- package/src/core/pipeline-handlers.ts +319 -0
- package/src/core/pipeline.ts +40 -15
- package/src/core/types.ts +26 -30
- package/src/index.ts +36 -26
- package/src/mode/native-guards.ts +2 -2
- package/src/mode/rlm-mode.ts +8 -11
- package/src/prompts/phases.ts +18 -39
- package/src/prompts/system.ts +167 -64
- package/src/prompts/user.ts +1 -5
- package/src/sandbox/protocol.ts +5 -17
- package/src/sandbox/sandbox-manager.ts +5 -5
- package/src/sandbox/sandbox.ts +67 -27
- package/src/sandbox/worker.py +534 -48
- package/src/state/paths.ts +1 -1
- package/src/state/reads.ts +12 -4
- package/src/state/resume.ts +26 -25
- package/src/state/rows.ts +2 -2
- package/src/text/parsing.ts +0 -6
- package/src/text/tokens.ts +7 -1
- package/src/tool/repl-details.ts +2 -3
- package/src/tool/repl-tool.ts +132 -337
- package/src/tool/rlm-aggregator.ts +7 -7
- package/src/tool/rlm-details.ts +6 -13
- package/src/tool/rlm-events.ts +14 -11
- package/src/tool/rlm-tool.ts +20 -38
- package/src/tool/subcall-render.ts +61 -9
- package/src/tool/subcall-store.ts +4 -2
- package/src/ui/config-panel.ts +43 -23
- package/src/ui/intro.ts +2 -1
- package/src/ui/status.ts +8 -5
- package/src/ui/theme-adapter.ts +36 -0
- package/src/ui/theme.ts +0 -25
- package/src/mode/input-router.ts +0 -23
- package/src/registry/edit-registry.ts +0 -22
- package/src/text/edits.ts +0 -164
- package/src/tool/apply-edits-tool.ts +0 -295
package/src/core/engine.ts
CHANGED
|
@@ -7,40 +7,37 @@
|
|
|
7
7
|
* back into `runRlm` at depth+1. Used for recursion and for headless/automation runs.
|
|
8
8
|
*
|
|
9
9
|
* When `config.pipeline` is on at depth 0: goal capture, artifact-gated advance_phase,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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).
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
15
16
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
16
17
|
import { buildInteractiveHandlers } from "../bridge/interactive.ts";
|
|
17
18
|
import { buildLibraryHandler } from "../bridge/library.ts";
|
|
19
|
+
import { mergeLibraryIntoContext } from "../context/library-context.ts";
|
|
18
20
|
import { createLlmBridge } from "../bridge/llm-query.ts";
|
|
19
21
|
import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
20
22
|
import { createRlmHandlers } from "../bridge/rlm-query.ts";
|
|
21
|
-
import { resolveModelId } from "../config/settings.ts";
|
|
23
|
+
import { displayModelRef, resolveModelId } from "../config/settings.ts";
|
|
22
24
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
23
25
|
import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
|
|
24
|
-
import {
|
|
26
|
+
import { phaseGuidance } from "../prompts/phases.ts";
|
|
25
27
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
26
28
|
import { PythonSandbox } from "../sandbox/sandbox.ts";
|
|
27
29
|
import {
|
|
28
|
-
advancePhase as validatePhaseTransition,
|
|
29
|
-
initialPhaseState,
|
|
30
30
|
phaseGatePrompt,
|
|
31
|
-
routeAfterValidate,
|
|
32
|
-
stageForArtifactKind,
|
|
33
|
-
STAGES,
|
|
34
31
|
type Phase,
|
|
35
32
|
type PhaseState,
|
|
36
33
|
type StageGateData,
|
|
37
34
|
} from "./pipeline.ts";
|
|
38
|
-
import
|
|
39
|
-
import {
|
|
35
|
+
import { PipelineController } from "./pipeline-handlers.ts";
|
|
36
|
+
import type { ValidationGateData } from "./gates.ts";
|
|
37
|
+
import { captureGoal, type GoalCapture } from "./artifacts.ts";
|
|
40
38
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
41
|
-
import { applyProposedEdits } from "../text/edits.ts";
|
|
42
39
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
43
|
-
import {
|
|
40
|
+
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
44
41
|
import { compactHistory, shouldCompact } from "./compaction.ts";
|
|
45
42
|
import { appendUserMessage } from "./history.ts";
|
|
46
43
|
import { runTurn } from "./iteration.ts";
|
|
@@ -59,8 +56,7 @@ import {
|
|
|
59
56
|
import { STATE_SCHEMA_VERSION } from "../state/rows.ts";
|
|
60
57
|
import type { PhaseRow, RunHeader } from "../state/rows.ts";
|
|
61
58
|
import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
|
|
62
|
-
import
|
|
63
|
-
import { formatError, isErrorText } from "../util/errors.ts";
|
|
59
|
+
import { formatError } from "../util/errors.ts";
|
|
64
60
|
|
|
65
61
|
|
|
66
62
|
export interface EngineDeps extends InteractiveDeps {
|
|
@@ -84,8 +80,6 @@ export interface EngineDeps extends InteractiveDeps {
|
|
|
84
80
|
export interface PhaseHistoryOptions {
|
|
85
81
|
readonly goal?: GoalCapture;
|
|
86
82
|
readonly validation?: ValidationGateData;
|
|
87
|
-
/** Fanout summary embedded so implement-exit result survives the history wipe. */
|
|
88
|
-
readonly implementSummary?: string;
|
|
89
83
|
/** Engine notice folded into the first user message (no console I/O). */
|
|
90
84
|
readonly notice?: string;
|
|
91
85
|
}
|
|
@@ -100,7 +94,7 @@ export function resetHistoryForPhase(
|
|
|
100
94
|
state: PhaseState,
|
|
101
95
|
options: PhaseHistoryOptions = {},
|
|
102
96
|
): ChatMsg[] {
|
|
103
|
-
const { goal, validation,
|
|
97
|
+
const { goal, validation, notice } = options;
|
|
104
98
|
const parts: string[] = [
|
|
105
99
|
`You are entering the '${state.current}' phase.`,
|
|
106
100
|
];
|
|
@@ -109,17 +103,19 @@ export function resetHistoryForPhase(
|
|
|
109
103
|
parts.push(`The user's verbatim brief: read ${goal.goalPath} from the REPL (open()).`);
|
|
110
104
|
parts.push(`Pre-run dirty baseline (exclude from delta judgment): ${goal.baselinePath}`);
|
|
111
105
|
}
|
|
112
|
-
for (const [p,
|
|
113
|
-
if (
|
|
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
|
+
);
|
|
114
113
|
}
|
|
115
114
|
if (validation) {
|
|
116
115
|
parts.push(
|
|
117
116
|
`Previous validation found ${validation.blockersCount} blocker(s) — read the validation artifact and address every blocker in the revised plan.`,
|
|
118
117
|
);
|
|
119
118
|
}
|
|
120
|
-
if (implementSummary) {
|
|
121
|
-
parts.push("Implement fanout result:", implementSummary);
|
|
122
|
-
}
|
|
123
119
|
parts.push(phaseGuidance(state.current));
|
|
124
120
|
parts.push("Your REPL variables persist; the chat history was reset to keep your window small.");
|
|
125
121
|
return [
|
|
@@ -156,7 +152,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
156
152
|
else emitter.emitStatus("error");
|
|
157
153
|
return {
|
|
158
154
|
answer: formatError(`unknown model override '${input.modelOverride}'`),
|
|
159
|
-
edits: [],
|
|
160
155
|
iterations: 0,
|
|
161
156
|
costUsd: 0,
|
|
162
157
|
inputTokens: 0,
|
|
@@ -182,29 +177,26 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
182
177
|
});
|
|
183
178
|
|
|
184
179
|
const llm = createLlmBridge({
|
|
185
|
-
workerModel: deps.workerModel,
|
|
180
|
+
workerModel: () => deps.workerModel,
|
|
186
181
|
registry: deps.registry,
|
|
187
|
-
|
|
188
|
-
maxPromptChars: deps.config.maxPromptChars,
|
|
189
|
-
maxConcurrent: deps.config.maxConcurrentSubcalls,
|
|
190
|
-
sampling: deps.config.subSampling,
|
|
182
|
+
config: () => deps.config,
|
|
191
183
|
signal: deps.signal,
|
|
192
184
|
onUsage: (u) => {
|
|
193
185
|
limits.addUsage(u);
|
|
194
186
|
deps.onUsage?.(u, "sub");
|
|
195
187
|
},
|
|
196
|
-
emitter,
|
|
197
|
-
parentId: selfReportId,
|
|
198
|
-
depth: input.depth,
|
|
188
|
+
emitter: () => emitter,
|
|
189
|
+
parentId: () => selfReportId,
|
|
190
|
+
depth: () => input.depth,
|
|
199
191
|
remainingBudget,
|
|
200
192
|
});
|
|
201
193
|
const rlm = createRlmHandlers({
|
|
202
194
|
run,
|
|
203
195
|
llm,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
parentNodeId: selfReportId,
|
|
196
|
+
config: () => deps.config,
|
|
197
|
+
modelLabel: (override) => displayModelRef(deps.registry, override, model),
|
|
198
|
+
emitter: () => emitter,
|
|
199
|
+
parentNodeId: () => selfReportId,
|
|
208
200
|
remainingBudget,
|
|
209
201
|
onChildUsage: (costUsd, inputTokens, outputTokens) => {
|
|
210
202
|
limits.addRaw(costUsd, inputTokens, outputTokens);
|
|
@@ -215,13 +207,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
215
207
|
let lastAnswer = "";
|
|
216
208
|
let compactions = 0;
|
|
217
209
|
let completedTurns = 0;
|
|
218
|
-
|
|
219
|
-
let
|
|
220
|
-
let lastSavedArtifact: Partial<Record<Phase, string>> = {};
|
|
221
|
-
/** Serviced ask_user_question rounds in the current phase (session-only; reset on transition). */
|
|
222
|
-
let askRoundsThisPhase = 0;
|
|
223
|
-
let pendingHistoryReset: ChatMsg[] | undefined;
|
|
224
|
-
let goal: GoalCapture | undefined;
|
|
210
|
+
/** Owns all pipeline state (phase, per-phase latest save, ask rounds, pending reset). */
|
|
211
|
+
let pipeline: PipelineController | undefined;
|
|
225
212
|
let nodeStatus: "done" | "error" = "done";
|
|
226
213
|
let persistOn = persist;
|
|
227
214
|
if (persist && deps.runState && !input.resume && runId) {
|
|
@@ -255,6 +242,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
255
242
|
artifactPath: string | undefined,
|
|
256
243
|
artifactPhase: Phase | undefined,
|
|
257
244
|
gateData: StageGateData | undefined,
|
|
245
|
+
supersededPath?: string,
|
|
258
246
|
): Promise<void> => {
|
|
259
247
|
if (!persistOn || !runId || !deps.runState) return;
|
|
260
248
|
const row: PhaseRow = {
|
|
@@ -267,60 +255,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
267
255
|
artifactPhase,
|
|
268
256
|
blockersCount: gateData?.kind === "validation" ? gateData.validation.blockersCount : undefined,
|
|
269
257
|
backwardJumps: state.backwardJumps,
|
|
258
|
+
supersededPath,
|
|
270
259
|
};
|
|
271
260
|
const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, row);
|
|
272
261
|
if (!ok) persistOn = false;
|
|
273
262
|
};
|
|
274
263
|
|
|
275
|
-
/** Clear lastSaved entries so a re-entered stage cannot re-gate with a stale artifact. */
|
|
276
|
-
const clearLastSaved = (...phases: readonly Phase[]): void => {
|
|
277
|
-
const next: Partial<Record<Phase, string>> = { ...lastSavedArtifact };
|
|
278
|
-
for (const p of phases) delete next[p];
|
|
279
|
-
lastSavedArtifact = next;
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
const runImplementFanout = async (planPath: string, plan: PlanGateData): Promise<string> => {
|
|
283
|
-
// Fanout children need a real RLM (sandbox + stage_edit); depth-cap degradation is a no-op.
|
|
284
|
-
if (input.depth + 1 >= deps.config.maxDepth) {
|
|
285
|
-
return formatError(
|
|
286
|
-
`implement fanout requires maxDepth >= ${input.depth + 2} so child RLMs can run (current maxDepth=${deps.config.maxDepth})`,
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
const lines = new Array<string>(plan.phases.length);
|
|
290
|
-
for (let i = 0; i < plan.phases.length; i++) {
|
|
291
|
-
const r = plan.phases[i];
|
|
292
|
-
if (r === undefined) continue;
|
|
293
|
-
// Keep the root sandbox's exec watchdog alive across long serial fanout work.
|
|
294
|
-
sandbox?.refreshWatchdog();
|
|
295
|
-
const prompt = buildImplementPhasePrompt(planPath, r);
|
|
296
|
-
const res = await rlm.childRun({
|
|
297
|
-
rootPrompt: prompt,
|
|
298
|
-
context: input.context,
|
|
299
|
-
depth: input.depth + 1,
|
|
300
|
-
label: `implement ${r.index + 1}/${r.total}: ${r.title}`,
|
|
301
|
-
});
|
|
302
|
-
sandbox?.refreshWatchdog();
|
|
303
|
-
// Serial patch-series: a later phase EDITS files an earlier phase CREATES —
|
|
304
|
-
// apply this child's edits BEFORE the next child starts.
|
|
305
|
-
const childEdits = res.edits ?? [];
|
|
306
|
-
const apply = await applyProposedEdits(childEdits, runCwd);
|
|
307
|
-
if (!apply.ok) {
|
|
308
|
-
return formatError(`implement halted at Phase ${r.n} (${r.title}): ${apply.error}`);
|
|
309
|
-
}
|
|
310
|
-
if (childEdits.length > 0) {
|
|
311
|
-
const next = new Array<ProposedEdit>(editsAcc.length + childEdits.length);
|
|
312
|
-
for (let j = 0; j < editsAcc.length; j++) next[j] = editsAcc[j];
|
|
313
|
-
for (let j = 0; j < childEdits.length; j++) next[editsAcc.length + j] = childEdits[j];
|
|
314
|
-
editsAcc = next;
|
|
315
|
-
}
|
|
316
|
-
lines[i] = `Phase ${r.n} (${r.title}): ${apply.applied} edit(s) applied — ${previewText(res.answer, 120)}`;
|
|
317
|
-
if (isErrorText(res.answer)) {
|
|
318
|
-
return formatError(`implement halted at Phase ${r.n}: ${res.answer}\n${lines.slice(0, i + 1).join("\n")}`);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
return `ok — implement complete (${plan.phases.length} phase(s), serial):\n${lines.join("\n")}\nNow advance_phase("validate").`;
|
|
322
|
-
};
|
|
323
|
-
|
|
324
264
|
try {
|
|
325
265
|
const pipelineOn = input.depth === 0 && deps.config.pipeline;
|
|
326
266
|
const meta = {
|
|
@@ -339,103 +279,22 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
339
279
|
libraryLoader: deps.config.libraryLoader,
|
|
340
280
|
});
|
|
341
281
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return formatError(
|
|
352
|
-
`artifact kind '${kind}' belongs to phase '${stage.phase}', but the pipeline is in '${current}'`,
|
|
353
|
-
);
|
|
354
|
-
}
|
|
355
|
-
const saved = saveArtifact(runCwd, stage.artifactDir, kind, content);
|
|
356
|
-
if (!saved.ok) return formatError(saved.error);
|
|
357
|
-
lastSavedArtifact = { ...lastSavedArtifact, [stage.phase]: saved.path };
|
|
358
|
-
return `ok — saved ${saved.path}. Call advance_phase when the artifact is complete (status: ready).`;
|
|
359
|
-
},
|
|
360
|
-
advancePhase: async (phase: string, summary: string | undefined): Promise<string> => {
|
|
361
|
-
const current = phaseState?.current ?? "clarify";
|
|
362
|
-
const outcome = validatePhaseTransition(current, phase);
|
|
363
|
-
if (!outcome.ok) return formatError(outcome.error);
|
|
364
|
-
|
|
365
|
-
// Clarify interview gate: engine counts serviced ask_user_question rounds
|
|
366
|
-
// (un-gameable — the model cannot advance without having actually asked).
|
|
367
|
-
if (current === "clarify" && askRoundsThisPhase === 0) {
|
|
368
|
-
return formatError(
|
|
369
|
-
"clarify requires at least one ask_user_question round — interview the user before advancing",
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// GATE: measure the CURRENT stage's latest save only (never fall back to
|
|
374
|
-
// phaseState.artifacts — those are completed-channel paths and may be stale
|
|
375
|
-
// across a corrective loop).
|
|
376
|
-
const stage = STAGES[current];
|
|
377
|
-
const artifactPath = lastSavedArtifact[current];
|
|
378
|
-
if (stage.artifactDir !== "" && artifactPath === undefined) {
|
|
379
|
-
return formatError(
|
|
380
|
-
`phase '${current}' has no saved artifact — call save_artifact("${stage.artifactKind}", content) first`,
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
let gateData: StageGateData | undefined;
|
|
384
|
-
if (stage.artifactDir !== "" && artifactPath !== undefined) {
|
|
385
|
-
const content = readArtifact(runCwd, artifactPath);
|
|
386
|
-
if (!content.ok) return formatError(content.error);
|
|
387
|
-
const gate = stage.gate(content.value, artifactPath, runCwd);
|
|
388
|
-
if (!gate.ok) return formatError(gate.error);
|
|
389
|
-
gateData = gate.value;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// Implement fanout runs BEFORE committing the transition: on failure the
|
|
393
|
-
// phase stays put and the error remains visible as the advance_phase return.
|
|
394
|
-
let implementSummary: string | undefined;
|
|
395
|
-
if (outcome.phase === "implement" && gateData?.kind === "plan") {
|
|
396
|
-
const planPath = artifactPath ?? "";
|
|
397
|
-
implementSummary = await runImplementFanout(planPath, gateData.plan);
|
|
398
|
-
if (isErrorText(implementSummary)) {
|
|
399
|
-
return implementSummary;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Transition accepted: persist row, schedule root history reset (fresh session).
|
|
404
|
-
const prevArtifacts = phaseState?.artifacts ?? {};
|
|
405
|
-
const nextArtifacts: Partial<Record<Phase, string>> = { ...prevArtifacts };
|
|
406
|
-
if (artifactPath !== undefined) nextArtifacts[current] = artifactPath;
|
|
407
|
-
phaseState = {
|
|
408
|
-
current: outcome.phase,
|
|
409
|
-
advancedAt: completedTurns,
|
|
410
|
-
summary,
|
|
411
|
-
artifacts: nextArtifacts,
|
|
412
|
-
backwardJumps: phaseState?.backwardJumps ?? 0,
|
|
413
|
-
};
|
|
414
|
-
await persistPhaseRow(phaseState, artifactPath, artifactPath !== undefined ? current : undefined, gateData);
|
|
415
|
-
// Leaving a stage: clear its lastSaved so a future re-entry must re-save.
|
|
416
|
-
clearLastSaved(current);
|
|
417
|
-
// Session-only ask counter (like lastSavedArtifact): reset on every accepted transition.
|
|
418
|
-
askRoundsThisPhase = 0;
|
|
419
|
-
pendingHistoryReset = resetHistoryForPhase(system, phaseState, {
|
|
420
|
-
goal,
|
|
421
|
-
implementSummary,
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
if (implementSummary !== undefined) {
|
|
425
|
-
return implementSummary;
|
|
426
|
-
}
|
|
427
|
-
const prevLabel = `was '${current}'`;
|
|
428
|
-
return `ok — phase advanced to '${outcome.phase}' (${prevLabel}${summary ? `, summary: ${summary.slice(0, 80)}` : ""})`;
|
|
429
|
-
},
|
|
430
|
-
}
|
|
431
|
-
: {};
|
|
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() : {};
|
|
432
291
|
const baseAsk = deps.config.askUserQuestion ? deps.onAskUserQuestion : undefined;
|
|
433
292
|
const interactiveHandlers = buildInteractiveHandlers({
|
|
434
293
|
onAskUserQuestion: baseAsk
|
|
435
294
|
? async (questions) => {
|
|
436
295
|
const answers = await baseAsk(questions);
|
|
437
296
|
// Count only successfully serviced root-depth rounds (handler already rejects depth>0).
|
|
438
|
-
|
|
297
|
+
pipeline?.noteAskRound();
|
|
439
298
|
return answers;
|
|
440
299
|
}
|
|
441
300
|
: undefined,
|
|
@@ -455,6 +314,18 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
455
314
|
const restoredSlots = input.resume && deps.runState && runId
|
|
456
315
|
? await readLibrarySidecars(deps.runState.cwd, deps.runState.dir, runId)
|
|
457
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
|
+
}
|
|
458
329
|
const libraryHandlers = deps.config.libraryLoader
|
|
459
330
|
? buildLibraryHandler({
|
|
460
331
|
cwd: runCwd,
|
|
@@ -462,6 +333,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
462
333
|
parentId: selfReportId,
|
|
463
334
|
signal: deps.signal,
|
|
464
335
|
startIndex: 1 + restoredSlots.reduce((m, s) => Math.max(m, s.index), 0),
|
|
336
|
+
loadedPrefixes: restoredPrefixes,
|
|
465
337
|
onLoaded: async (index, payload) => {
|
|
466
338
|
if (!persistOn || !runId || !deps.runState) return;
|
|
467
339
|
await writeContextSidecar(
|
|
@@ -480,6 +352,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
480
352
|
signal: deps.signal,
|
|
481
353
|
initTimeoutMs: deps.config.sandboxInitTimeoutMs,
|
|
482
354
|
maxPromptChars: deps.config.maxPromptChars,
|
|
355
|
+
// Pipeline at depth 0 is read-only: guard open() write modes in the worker.
|
|
356
|
+
readOnly: pipelineOn,
|
|
483
357
|
handlers: { ...llm, ...rlm, ...phaseHandlers, ...interactiveHandlers, ...libraryHandlers },
|
|
484
358
|
});
|
|
485
359
|
|
|
@@ -489,46 +363,16 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
489
363
|
if (input.resume) {
|
|
490
364
|
limits.addRaw(input.resume.usageSeed.costUsd, input.resume.usageSeed.inputTokens, input.resume.usageSeed.outputTokens);
|
|
491
365
|
best = input.resume.best;
|
|
492
|
-
editsAcc = [];
|
|
493
366
|
compactions = input.resume.compactions;
|
|
494
367
|
completedTurns = input.resume.completedTurns;
|
|
495
|
-
if (input.resume.phase)
|
|
496
|
-
const resumePhase = input.resume.phase;
|
|
497
|
-
const artifacts: Partial<Record<Phase, string>> = {};
|
|
498
|
-
if (resumePhase.artifacts) {
|
|
499
|
-
for (const [k, v] of Object.entries(resumePhase.artifacts)) {
|
|
500
|
-
if (
|
|
501
|
-
v !== undefined
|
|
502
|
-
&& (k === "clarify" || k === "research" || k === "blueprint" || k === "implement" || k === "validate")
|
|
503
|
-
) {
|
|
504
|
-
artifacts[k] = v;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
phaseState = {
|
|
509
|
-
current: resumePhase.current as Phase,
|
|
510
|
-
advancedAt: resumePhase.advancedAt,
|
|
511
|
-
summary: resumePhase.summary,
|
|
512
|
-
artifacts,
|
|
513
|
-
backwardJumps: resumePhase.backwardJumps ?? 0,
|
|
514
|
-
};
|
|
515
|
-
// lastSaved is session-only: never rehydrate from trail (would re-gate stale
|
|
516
|
-
// plan/validation after loop-back / mid-stage resume without a fresh save).
|
|
517
|
-
lastSavedArtifact = {};
|
|
518
|
-
// askRoundsThisPhase is session-only (like lastSavedArtifact): a resume mid-clarify
|
|
519
|
-
// restarts the interview count so the model must ask again in this process.
|
|
520
|
-
askRoundsThisPhase = 0;
|
|
521
|
-
}
|
|
368
|
+
if (input.resume.phase) pipeline.seedFromResume(input.resume.phase);
|
|
522
369
|
} else if (pipelineOn) {
|
|
523
370
|
// Goal capture (script, no LLM) + seed phase state + fresh history.
|
|
524
371
|
const captured = captureGoal(runCwd, input.rootPrompt);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
// Fail-soft: fold into the first reset message (never console — corrupts TUI).
|
|
530
|
-
goalNotice = `Note: goal artifact could not be written (${captured.error}); the brief remains only in the system prompt.`;
|
|
531
|
-
}
|
|
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.`;
|
|
532
376
|
// Clarify only when interviews are enabled AND the host wired a callback.
|
|
533
377
|
// Config alone is not enough: without onAskUserQuestion every ask throws and
|
|
534
378
|
// the run would burn maxIterations stuck at clarify (askRounds stays 0).
|
|
@@ -536,18 +380,19 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
536
380
|
deps.config.askUserQuestion && deps.onAskUserQuestion !== undefined
|
|
537
381
|
? "clarify"
|
|
538
382
|
: "research";
|
|
539
|
-
|
|
540
|
-
history = resetHistoryForPhase(system, phaseState, { goal, notice: goalNotice });
|
|
383
|
+
history = pipeline.seedFresh(startPhase, captured.ok ? captured.value : undefined, goalNotice);
|
|
541
384
|
}
|
|
542
385
|
|
|
543
386
|
// Context: serialize ContextBundle to sandbox-ready JSON array, pass raw strings through.
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
387
|
+
// Resume: merge library sidecars into the single `context` list (no context_N slots).
|
|
388
|
+
let contextValue: unknown =
|
|
389
|
+
typeof input.context === "object" && input.context !== null && "files" in input.context
|
|
390
|
+
? serializeForSandbox(input.context as ContextBundle)
|
|
391
|
+
: input.context;
|
|
548
392
|
for (const slot of restoredSlots) {
|
|
549
|
-
|
|
393
|
+
contextValue = mergeLibraryIntoContext(contextValue, slot.payload);
|
|
550
394
|
}
|
|
395
|
+
await sandbox.loadContext(contextValue);
|
|
551
396
|
if (input.resume?.snapshotTurn !== undefined && deps.runState && runId && sessionNonce) // R-C1: restore only for same-session (sessionNonce present)
|
|
552
397
|
await sandbox.restore(snapshotPath(deps.runState.cwd, deps.runState.dir, runId, input.resume.snapshotTurn), sessionNonce);
|
|
553
398
|
for (let i = startTurn; i < deps.config.maxIterations; i++) {
|
|
@@ -556,9 +401,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
556
401
|
else emitter.emitTurn(i + 1, deps.config.maxIterations);
|
|
557
402
|
|
|
558
403
|
// Apply deferred history reset from a prior advance_phase (fresh session policy).
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
404
|
+
const scheduledReset = pipeline.takePendingReset();
|
|
405
|
+
if (scheduledReset !== undefined) {
|
|
406
|
+
history = scheduledReset;
|
|
562
407
|
pendingReplOutputs = undefined;
|
|
563
408
|
}
|
|
564
409
|
|
|
@@ -594,7 +439,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
594
439
|
pendingReplOutputs = undefined;
|
|
595
440
|
}
|
|
596
441
|
|
|
597
|
-
const gateMsg = deps.config.pipeline ? phaseGatePrompt(
|
|
442
|
+
const gateMsg = deps.config.pipeline ? phaseGatePrompt(pipeline.phase, completedTurns) : undefined;
|
|
598
443
|
const gateUserMsg = gateMsg ? `[${new Date().toISOString()}] ${gateMsg}` : undefined;
|
|
599
444
|
// Phase guidance lives only in resetHistoryForPhase (fresh session) — do not re-inject
|
|
600
445
|
// into every turn prompt (avoids duplication on turn 1 and dead post-transition flags).
|
|
@@ -629,79 +474,31 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
629
474
|
if (answerContent) best = answerContent;
|
|
630
475
|
else if (!best && turn.response.trim()) best = turn.response;
|
|
631
476
|
completedTurns = i + 1;
|
|
632
|
-
const proposedEdits = collectEdits(turn.results);
|
|
633
|
-
if (proposedEdits.length > 0) editsAcc = proposedEdits;
|
|
634
477
|
const final = finalAnswerOf(turn.results);
|
|
635
478
|
if (final != null) {
|
|
636
|
-
// Validate-phase finalize:
|
|
637
|
-
//
|
|
638
|
-
if (pipelineOn &&
|
|
639
|
-
const
|
|
640
|
-
if (
|
|
641
|
-
// Reject finalize — push error into next turn.
|
|
642
|
-
history.push({ role: "assistant", content: turn.response });
|
|
643
|
-
pendingReplOutputs = formatError(
|
|
644
|
-
"finalize rejected — save the validation artifact first via save_artifact(\"validation\", content) with status: ready, blockers_count, and verdict",
|
|
645
|
-
);
|
|
646
|
-
continue;
|
|
647
|
-
}
|
|
648
|
-
const content = readArtifact(runCwd, vPath);
|
|
649
|
-
if (!content.ok) {
|
|
650
|
-
history.push({ role: "assistant", content: turn.response });
|
|
651
|
-
pendingReplOutputs = formatError(content.error);
|
|
652
|
-
continue;
|
|
653
|
-
}
|
|
654
|
-
const gate = STAGES.validate.gate(content.value, vPath, runCwd);
|
|
655
|
-
if (!gate.ok) {
|
|
656
|
-
history.push({ role: "assistant", content: turn.response });
|
|
657
|
-
pendingReplOutputs = formatError(gate.error);
|
|
658
|
-
continue;
|
|
659
|
-
}
|
|
660
|
-
if (gate.value.kind !== "validation") {
|
|
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") {
|
|
661
484
|
history.push({ role: "assistant", content: turn.response });
|
|
662
|
-
pendingReplOutputs =
|
|
485
|
+
pendingReplOutputs = outcome.error;
|
|
663
486
|
continue;
|
|
664
487
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
validation,
|
|
668
|
-
phaseState.backwardJumps,
|
|
669
|
-
deps.config.maxBackwardJumps,
|
|
670
|
-
);
|
|
671
|
-
if (route.kind === "loop-back") {
|
|
672
|
-
// Keep clarify/research; record validate for the reset message; DROP blueprint so
|
|
673
|
-
// the model must write a new plan. Clear lastSaved so gates cannot re-use
|
|
674
|
-
// round-1 plan/validation without a fresh save_artifact.
|
|
675
|
-
const nextArtifacts: Partial<Record<Phase, string>> = {
|
|
676
|
-
clarify: phaseState.artifacts.clarify,
|
|
677
|
-
research: phaseState.artifacts.research,
|
|
678
|
-
validate: vPath,
|
|
679
|
-
};
|
|
680
|
-
phaseState = {
|
|
681
|
-
current: "blueprint",
|
|
682
|
-
advancedAt: completedTurns,
|
|
683
|
-
summary: `loop-back: ${validation.blockersCount} blocker(s)`,
|
|
684
|
-
artifacts: nextArtifacts,
|
|
685
|
-
backwardJumps: phaseState.backwardJumps + 1,
|
|
686
|
-
};
|
|
687
|
-
await persistPhaseRow(phaseState, vPath, "validate", gate.value);
|
|
688
|
-
clearLastSaved("blueprint", "validate");
|
|
689
|
-
askRoundsThisPhase = 0;
|
|
690
|
-
history = resetHistoryForPhase(system, phaseState, { goal, validation });
|
|
488
|
+
if (outcome.kind === "loop-back") {
|
|
489
|
+
history = outcome.history;
|
|
691
490
|
pendingReplOutputs = undefined;
|
|
692
|
-
pendingHistoryReset = undefined;
|
|
693
491
|
continue;
|
|
694
492
|
}
|
|
695
|
-
if (
|
|
696
|
-
const
|
|
697
|
-
const halted = result(report, i + 1, limits, editsAcc);
|
|
493
|
+
if (outcome.kind === "halt") {
|
|
494
|
+
const halted = result(outcome.report, i + 1, limits);
|
|
698
495
|
await recordTerminal("completed", halted);
|
|
699
496
|
lastAnswer = halted.answer;
|
|
700
497
|
return halted;
|
|
701
498
|
}
|
|
702
|
-
//
|
|
499
|
+
// outcome.kind === "accept" — take the model's final answer
|
|
703
500
|
}
|
|
704
|
-
const done = result(final, i + 1, limits
|
|
501
|
+
const done = result(final, i + 1, limits);
|
|
705
502
|
await recordTerminal("completed", done);
|
|
706
503
|
lastAnswer = done.answer;
|
|
707
504
|
return done;
|
|
@@ -711,9 +508,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
711
508
|
// Always capture this turn's REPL outputs for the JSONL trail (fidelity).
|
|
712
509
|
const turnReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
|
|
713
510
|
// If advance_phase scheduled a history reset, apply it now (do not pollute fresh history).
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
511
|
+
const nextReset = pipeline.takePendingReset();
|
|
512
|
+
if (nextReset !== undefined) {
|
|
513
|
+
history = nextReset;
|
|
717
514
|
// Fanout/advance result is already embedded in the reset user message — do not
|
|
718
515
|
// also append raw REPL stdout as a next-turn user message.
|
|
719
516
|
pendingReplOutputs = undefined;
|
|
@@ -733,7 +530,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
733
530
|
// Trail keeps the real REPL output even when history was reset (issue #9).
|
|
734
531
|
replOutputs: turnReplOutputs || undefined,
|
|
735
532
|
answerContent: answerContent || undefined,
|
|
736
|
-
edits: proposedEdits.length > 0 ? proposedEdits : undefined,
|
|
737
533
|
error: turnHadError(turn.results),
|
|
738
534
|
usage: { costUsd: turn.usage.cost.total, inputTokens: turn.usage.input, outputTokens: turn.usage.output }, // B2: Usage has .input/.output, not .inputTokens/.outputTokens
|
|
739
535
|
cumulativeDurationMs: limits.usage().durationMs, // B3: required by TurnRow, seeds LimitGuard clock on resume (CA)
|
|
@@ -744,21 +540,21 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
744
540
|
}
|
|
745
541
|
}
|
|
746
542
|
if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
|
|
747
|
-
const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits
|
|
543
|
+
const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
|
|
748
544
|
await recordTerminal("finalized", finalized);
|
|
749
545
|
lastAnswer = finalized.answer;
|
|
750
546
|
return finalized;
|
|
751
547
|
} catch (err) {
|
|
752
548
|
// Abort is a user action — resolve with the best partial, not an error.
|
|
753
549
|
if (deps.signal?.aborted) {
|
|
754
|
-
const aborted = result(best.trim() || "(aborted)", completedTurns, limits
|
|
550
|
+
const aborted = result(best.trim() || "(aborted)", completedTurns, limits);
|
|
755
551
|
await recordTerminal("aborted", aborted);
|
|
756
552
|
lastAnswer = aborted.answer;
|
|
757
553
|
return aborted;
|
|
758
554
|
}
|
|
759
555
|
if (err instanceof LimitError) {
|
|
760
556
|
nodeStatus = "error";
|
|
761
|
-
const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits
|
|
557
|
+
const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits);
|
|
762
558
|
await recordTerminal("stopped", stopped);
|
|
763
559
|
lastAnswer = stopped.answer;
|
|
764
560
|
return stopped;
|
|
@@ -775,7 +571,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
775
571
|
});
|
|
776
572
|
} else {
|
|
777
573
|
if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
|
|
778
|
-
emitter.emitEdits(editsAcc.length > 0 ? editsAcc : []);
|
|
779
574
|
emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
|
|
780
575
|
}
|
|
781
576
|
await sandbox?.dispose();
|
|
@@ -784,9 +579,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
784
579
|
return run;
|
|
785
580
|
}
|
|
786
581
|
|
|
787
|
-
function result(answer: string, iterations: number, limits: LimitGuard
|
|
582
|
+
function result(answer: string, iterations: number, limits: LimitGuard): RlmResult {
|
|
788
583
|
const u = limits.usage();
|
|
789
|
-
return { answer,
|
|
584
|
+
return { answer, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
|
|
790
585
|
}
|
|
791
586
|
|
|
792
587
|
/** Out of turns: ask the model for its best final answer (plain text). */
|