@hicaru/pi-rlm 0.1.8 → 0.1.9
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 +1 -0
- package/src/bridge/rlm-query.ts +2 -4
- package/src/context/library-context.ts +209 -22
- package/src/context/repomix-context.ts +2 -48
- package/src/core/answer.ts +1 -10
- package/src/core/critique.ts +92 -0
- package/src/core/engine.ts +130 -134
- package/src/core/gates.ts +30 -1
- package/src/core/pipeline.ts +38 -13
- package/src/core/types.ts +1 -3
- package/src/index.ts +1 -9
- package/src/mode/native-guards.ts +2 -2
- package/src/prompts/phases.ts +18 -39
- package/src/prompts/system.ts +31 -19
- package/src/sandbox/protocol.ts +5 -10
- package/src/sandbox/sandbox.ts +43 -9
- package/src/sandbox/worker.py +180 -48
- package/src/state/resume.ts +21 -14
- package/src/state/rows.ts +2 -2
- package/src/text/tokens.ts +7 -1
- package/src/tool/repl-details.ts +2 -3
- package/src/tool/repl-tool.ts +29 -55
- package/src/tool/rlm-aggregator.ts +7 -7
- package/src/tool/rlm-details.ts +6 -3
- package/src/tool/rlm-events.ts +14 -11
- package/src/tool/rlm-tool.ts +2 -7
- package/src/tool/subcall-store.ts +2 -0
- package/src/ui/config-panel.ts +2 -2
- package/src/registry/edit-registry.ts +0 -22
- package/src/text/edits.ts +0 -164
- package/src/tool/apply-edits-tool.ts +0 -295
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight critique for stage artifacts — port of codex-bais `model_critique`.
|
|
3
|
+
*
|
|
4
|
+
* Blocking truth comes from `stage.gate` (the same function `advance_phase` runs), so the
|
|
5
|
+
* two can never drift. `warnings` are advisory model-risk diagnostics that never block.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
countBulletsUnderHeading,
|
|
9
|
+
phasesMissingSuccessCriteria,
|
|
10
|
+
sectionHasNonEmptyBody,
|
|
11
|
+
} from "./gates.ts";
|
|
12
|
+
import type { StageDef, StageGateData } from "./pipeline.ts";
|
|
13
|
+
|
|
14
|
+
export interface Critique {
|
|
15
|
+
/** Blocking — `advance_phase` will reject while non-empty. */
|
|
16
|
+
readonly issues: readonly string[];
|
|
17
|
+
/** Advisory — surfaced to the model, never blocking. */
|
|
18
|
+
readonly warnings: readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* Convenience alias for `issues.length === 0` — do not set independently;
|
|
21
|
+
* always derive from `issues`.
|
|
22
|
+
*/
|
|
23
|
+
readonly canAdvance: boolean;
|
|
24
|
+
/** Gate payload when `canAdvance` — reused by advance_phase (no second gate run). */
|
|
25
|
+
readonly gateData?: StageGateData;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const NO_STRINGS: readonly string[] = Object.freeze([]);
|
|
29
|
+
|
|
30
|
+
/** Advisory checks per artifact kind. Pure; returns a frozen array. */
|
|
31
|
+
function advisoriesFor(stage: StageDef, content: string): readonly string[] {
|
|
32
|
+
switch (stage.artifactKind) {
|
|
33
|
+
case "plan": {
|
|
34
|
+
const missing = phasesMissingSuccessCriteria(content);
|
|
35
|
+
if (missing.length > 0) {
|
|
36
|
+
return Object.freeze([
|
|
37
|
+
`${missing.join(", ")} ${missing.length === 1 ? "has" : "have"} no `
|
|
38
|
+
+ "'### Success Criteria' — validate cannot check them",
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
return NO_STRINGS;
|
|
42
|
+
}
|
|
43
|
+
case "clarification": {
|
|
44
|
+
const open = countBulletsUnderHeading(content, "Open Questions");
|
|
45
|
+
if (open > 0) {
|
|
46
|
+
return Object.freeze([
|
|
47
|
+
`${open} Open Question(s) carried into blueprint — re-ask any that block the design`,
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
return NO_STRINGS;
|
|
51
|
+
}
|
|
52
|
+
case "research": {
|
|
53
|
+
if (!sectionHasNonEmptyBody(content, "Findings")) {
|
|
54
|
+
return Object.freeze(["research artifact has no '## Findings' section"]);
|
|
55
|
+
}
|
|
56
|
+
return NO_STRINGS;
|
|
57
|
+
}
|
|
58
|
+
default:
|
|
59
|
+
return NO_STRINGS;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function critiqueArtifact(
|
|
64
|
+
stage: StageDef,
|
|
65
|
+
content: string,
|
|
66
|
+
path: string,
|
|
67
|
+
cwd: string,
|
|
68
|
+
): Critique {
|
|
69
|
+
const gate = stage.gate(content, path, cwd);
|
|
70
|
+
const issues = gate.ok ? NO_STRINGS : Object.freeze([gate.error]);
|
|
71
|
+
return Object.freeze({
|
|
72
|
+
issues,
|
|
73
|
+
warnings: advisoriesFor(stage, content),
|
|
74
|
+
canAdvance: issues.length === 0,
|
|
75
|
+
gateData: gate.ok ? gate.value : undefined,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Human-readable renderer for the `save_artifact` return value. */
|
|
80
|
+
export function formatCritique(critique: Critique): string {
|
|
81
|
+
if (critique.issues.length === 0 && critique.warnings.length === 0) {
|
|
82
|
+
return "gate: clean — call advance_phase when ready.";
|
|
83
|
+
}
|
|
84
|
+
const lines = new Array<string>(critique.issues.length + critique.warnings.length);
|
|
85
|
+
let n = 0;
|
|
86
|
+
for (let i = 0; i < critique.issues.length; i++) lines[n++] = ` BLOCKER: ${critique.issues[i]}`;
|
|
87
|
+
for (let i = 0; i < critique.warnings.length; i++) lines[n++] = ` warning: ${critique.warnings[i]}`;
|
|
88
|
+
const head = critique.canAdvance
|
|
89
|
+
? "gate: passes, with advisories —"
|
|
90
|
+
: "gate: WOULD REJECT — fix before advance_phase:";
|
|
91
|
+
return `${head}\n${lines.join("\n")}`;
|
|
92
|
+
}
|
package/src/core/engine.ts
CHANGED
|
@@ -7,40 +7,47 @@
|
|
|
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
23
|
import { 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
30
|
advancePhase as validatePhaseTransition,
|
|
29
31
|
initialPhaseState,
|
|
32
|
+
isPhase,
|
|
30
33
|
phaseGatePrompt,
|
|
34
|
+
PHASES,
|
|
35
|
+
reconcilePhase,
|
|
31
36
|
routeAfterValidate,
|
|
32
37
|
stageForArtifactKind,
|
|
33
38
|
STAGES,
|
|
39
|
+
type ArtifactRef,
|
|
34
40
|
type Phase,
|
|
35
41
|
type PhaseState,
|
|
42
|
+
type SavedArtifact,
|
|
36
43
|
type StageGateData,
|
|
37
44
|
} from "./pipeline.ts";
|
|
38
|
-
import
|
|
45
|
+
import { critiqueArtifact, formatCritique } from "./critique.ts";
|
|
46
|
+
import type { ValidationGateData } from "./gates.ts";
|
|
39
47
|
import { captureGoal, readArtifact, saveArtifact, type GoalCapture } from "./artifacts.ts";
|
|
40
48
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
41
|
-
import { applyProposedEdits } from "../text/edits.ts";
|
|
42
49
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
43
|
-
import {
|
|
50
|
+
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
44
51
|
import { compactHistory, shouldCompact } from "./compaction.ts";
|
|
45
52
|
import { appendUserMessage } from "./history.ts";
|
|
46
53
|
import { runTurn } from "./iteration.ts";
|
|
@@ -59,8 +66,7 @@ import {
|
|
|
59
66
|
import { STATE_SCHEMA_VERSION } from "../state/rows.ts";
|
|
60
67
|
import type { PhaseRow, RunHeader } from "../state/rows.ts";
|
|
61
68
|
import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
|
|
62
|
-
import
|
|
63
|
-
import { formatError, isErrorText } from "../util/errors.ts";
|
|
69
|
+
import { formatError } from "../util/errors.ts";
|
|
64
70
|
|
|
65
71
|
|
|
66
72
|
export interface EngineDeps extends InteractiveDeps {
|
|
@@ -84,8 +90,6 @@ export interface EngineDeps extends InteractiveDeps {
|
|
|
84
90
|
export interface PhaseHistoryOptions {
|
|
85
91
|
readonly goal?: GoalCapture;
|
|
86
92
|
readonly validation?: ValidationGateData;
|
|
87
|
-
/** Fanout summary embedded so implement-exit result survives the history wipe. */
|
|
88
|
-
readonly implementSummary?: string;
|
|
89
93
|
/** Engine notice folded into the first user message (no console I/O). */
|
|
90
94
|
readonly notice?: string;
|
|
91
95
|
}
|
|
@@ -100,7 +104,7 @@ export function resetHistoryForPhase(
|
|
|
100
104
|
state: PhaseState,
|
|
101
105
|
options: PhaseHistoryOptions = {},
|
|
102
106
|
): ChatMsg[] {
|
|
103
|
-
const { goal, validation,
|
|
107
|
+
const { goal, validation, notice } = options;
|
|
104
108
|
const parts: string[] = [
|
|
105
109
|
`You are entering the '${state.current}' phase.`,
|
|
106
110
|
];
|
|
@@ -109,17 +113,19 @@ export function resetHistoryForPhase(
|
|
|
109
113
|
parts.push(`The user's verbatim brief: read ${goal.goalPath} from the REPL (open()).`);
|
|
110
114
|
parts.push(`Pre-run dirty baseline (exclude from delta judgment): ${goal.baselinePath}`);
|
|
111
115
|
}
|
|
112
|
-
for (const [p,
|
|
113
|
-
if (
|
|
116
|
+
for (const [p, ref] of Object.entries(state.artifacts)) {
|
|
117
|
+
if (ref === undefined) continue;
|
|
118
|
+
parts.push(
|
|
119
|
+
ref.status === "superseded"
|
|
120
|
+
? `Superseded artifact from '${p}' (rejected by validation): ${ref.path} — read it and the validation before re-planning; do not repeat its blockers.`
|
|
121
|
+
: `Artifact from '${p}': ${ref.path}`,
|
|
122
|
+
);
|
|
114
123
|
}
|
|
115
124
|
if (validation) {
|
|
116
125
|
parts.push(
|
|
117
126
|
`Previous validation found ${validation.blockersCount} blocker(s) — read the validation artifact and address every blocker in the revised plan.`,
|
|
118
127
|
);
|
|
119
128
|
}
|
|
120
|
-
if (implementSummary) {
|
|
121
|
-
parts.push("Implement fanout result:", implementSummary);
|
|
122
|
-
}
|
|
123
129
|
parts.push(phaseGuidance(state.current));
|
|
124
130
|
parts.push("Your REPL variables persist; the chat history was reset to keep your window small.");
|
|
125
131
|
return [
|
|
@@ -156,7 +162,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
156
162
|
else emitter.emitStatus("error");
|
|
157
163
|
return {
|
|
158
164
|
answer: formatError(`unknown model override '${input.modelOverride}'`),
|
|
159
|
-
edits: [],
|
|
160
165
|
iterations: 0,
|
|
161
166
|
costUsd: 0,
|
|
162
167
|
inputTokens: 0,
|
|
@@ -215,9 +220,14 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
215
220
|
let lastAnswer = "";
|
|
216
221
|
let compactions = 0;
|
|
217
222
|
let completedTurns = 0;
|
|
218
|
-
let editsAcc: ProposedEdit[] = [];
|
|
219
223
|
let phaseState: PhaseState | undefined;
|
|
220
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Latest save per phase: path + optional gate memo (single record so they cannot desync).
|
|
226
|
+
* Invalidated by clearLastSaved on phase exit / loop-back.
|
|
227
|
+
*/
|
|
228
|
+
let lastSaved: Partial<Record<Phase, SavedArtifact>> = {};
|
|
229
|
+
/** Advisory warnings accumulated from save_artifact critiques (TUI). */
|
|
230
|
+
let pipelineWarnings: string[] = [];
|
|
221
231
|
/** Serviced ask_user_question rounds in the current phase (session-only; reset on transition). */
|
|
222
232
|
let askRoundsThisPhase = 0;
|
|
223
233
|
let pendingHistoryReset: ChatMsg[] | undefined;
|
|
@@ -255,6 +265,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
255
265
|
artifactPath: string | undefined,
|
|
256
266
|
artifactPhase: Phase | undefined,
|
|
257
267
|
gateData: StageGateData | undefined,
|
|
268
|
+
supersededPath?: string,
|
|
258
269
|
): Promise<void> => {
|
|
259
270
|
if (!persistOn || !runId || !deps.runState) return;
|
|
260
271
|
const row: PhaseRow = {
|
|
@@ -267,58 +278,17 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
267
278
|
artifactPhase,
|
|
268
279
|
blockersCount: gateData?.kind === "validation" ? gateData.validation.blockersCount : undefined,
|
|
269
280
|
backwardJumps: state.backwardJumps,
|
|
281
|
+
supersededPath,
|
|
270
282
|
};
|
|
271
283
|
const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, row);
|
|
272
284
|
if (!ok) persistOn = false;
|
|
273
285
|
};
|
|
274
286
|
|
|
275
|
-
/** Clear lastSaved
|
|
287
|
+
/** Clear lastSaved so a re-entered stage cannot re-gate with a stale artifact. */
|
|
276
288
|
const clearLastSaved = (...phases: readonly Phase[]): void => {
|
|
277
|
-
const next: Partial<Record<Phase,
|
|
289
|
+
const next: Partial<Record<Phase, SavedArtifact>> = { ...lastSaved };
|
|
278
290
|
for (const p of phases) delete next[p];
|
|
279
|
-
|
|
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").`;
|
|
291
|
+
lastSaved = next;
|
|
322
292
|
};
|
|
323
293
|
|
|
324
294
|
try {
|
|
@@ -346,7 +316,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
346
316
|
if (stage === undefined) {
|
|
347
317
|
return formatError(`unknown artifact kind '${kind}' (valid: clarification, research, plan, validation)`);
|
|
348
318
|
}
|
|
349
|
-
const current = phaseState?.current ??
|
|
319
|
+
const current = phaseState?.current ?? PHASES[0];
|
|
350
320
|
if (stage.phase !== current) {
|
|
351
321
|
return formatError(
|
|
352
322
|
`artifact kind '${kind}' belongs to phase '${stage.phase}', but the pipeline is in '${current}'`,
|
|
@@ -354,11 +324,29 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
354
324
|
}
|
|
355
325
|
const saved = saveArtifact(runCwd, stage.artifactDir, kind, content);
|
|
356
326
|
if (!saved.ok) return formatError(saved.error);
|
|
357
|
-
|
|
358
|
-
|
|
327
|
+
// Preflight: run the SAME gate advance_phase will run, now instead of a turn later.
|
|
328
|
+
const critique = critiqueArtifact(stage, content, saved.path, runCwd);
|
|
329
|
+
// One assignment: path + optional gate memo (undefined when gate failed).
|
|
330
|
+
lastSaved = {
|
|
331
|
+
...lastSaved,
|
|
332
|
+
[stage.phase]: Object.freeze({
|
|
333
|
+
path: saved.path,
|
|
334
|
+
gateData: critique.gateData,
|
|
335
|
+
}),
|
|
336
|
+
};
|
|
337
|
+
if (critique.warnings.length > 0) {
|
|
338
|
+
const next = new Array<string>(pipelineWarnings.length + critique.warnings.length);
|
|
339
|
+
for (let i = 0; i < pipelineWarnings.length; i++) next[i] = pipelineWarnings[i];
|
|
340
|
+
for (let i = 0; i < critique.warnings.length; i++) {
|
|
341
|
+
next[pipelineWarnings.length + i] = critique.warnings[i];
|
|
342
|
+
}
|
|
343
|
+
pipelineWarnings = next;
|
|
344
|
+
emitter.emitWarnings(Object.freeze([...pipelineWarnings]));
|
|
345
|
+
}
|
|
346
|
+
return `ok — saved ${saved.path}.\n${formatCritique(critique)}`;
|
|
359
347
|
},
|
|
360
348
|
advancePhase: async (phase: string, summary: string | undefined): Promise<string> => {
|
|
361
|
-
const current = phaseState?.current ??
|
|
349
|
+
const current = phaseState?.current ?? PHASES[0];
|
|
362
350
|
const outcome = validatePhaseTransition(current, phase);
|
|
363
351
|
if (!outcome.ok) return formatError(outcome.error);
|
|
364
352
|
|
|
@@ -372,9 +360,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
372
360
|
|
|
373
361
|
// GATE: measure the CURRENT stage's latest save only (never fall back to
|
|
374
362
|
// phaseState.artifacts — those are completed-channel paths and may be stale
|
|
375
|
-
// across a corrective loop).
|
|
363
|
+
// across a corrective loop). Prefer the memo filled by save_artifact.
|
|
376
364
|
const stage = STAGES[current];
|
|
377
|
-
const
|
|
365
|
+
const savedEntry = lastSaved[current];
|
|
366
|
+
const artifactPath = savedEntry?.path;
|
|
378
367
|
if (stage.artifactDir !== "" && artifactPath === undefined) {
|
|
379
368
|
return formatError(
|
|
380
369
|
`phase '${current}' has no saved artifact — call save_artifact("${stage.artifactKind}", content) first`,
|
|
@@ -382,28 +371,27 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
382
371
|
}
|
|
383
372
|
let gateData: StageGateData | undefined;
|
|
384
373
|
if (stage.artifactDir !== "" && artifactPath !== undefined) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
implementSummary = await runImplementFanout(planPath, gateData.plan);
|
|
398
|
-
if (isErrorText(implementSummary)) {
|
|
399
|
-
return implementSummary;
|
|
374
|
+
if (savedEntry?.gateData !== undefined) {
|
|
375
|
+
gateData = savedEntry.gateData;
|
|
376
|
+
} else {
|
|
377
|
+
const content = readArtifact(runCwd, artifactPath);
|
|
378
|
+
if (!content.ok) return formatError(content.error);
|
|
379
|
+
const gate = stage.gate(content.value, artifactPath, runCwd);
|
|
380
|
+
if (!gate.ok) return formatError(gate.error);
|
|
381
|
+
gateData = gate.value;
|
|
382
|
+
lastSaved = {
|
|
383
|
+
...lastSaved,
|
|
384
|
+
[current]: Object.freeze({ path: artifactPath, gateData }),
|
|
385
|
+
};
|
|
400
386
|
}
|
|
401
387
|
}
|
|
402
388
|
|
|
403
389
|
// Transition accepted: persist row, schedule root history reset (fresh session).
|
|
404
390
|
const prevArtifacts = phaseState?.artifacts ?? {};
|
|
405
|
-
const nextArtifacts: Partial<Record<Phase,
|
|
406
|
-
if (artifactPath !== undefined)
|
|
391
|
+
const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = { ...prevArtifacts };
|
|
392
|
+
if (artifactPath !== undefined) {
|
|
393
|
+
nextArtifacts[current] = Object.freeze({ path: artifactPath, status: "active" });
|
|
394
|
+
}
|
|
407
395
|
phaseState = {
|
|
408
396
|
current: outcome.phase,
|
|
409
397
|
advancedAt: completedTurns,
|
|
@@ -412,20 +400,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
412
400
|
backwardJumps: phaseState?.backwardJumps ?? 0,
|
|
413
401
|
};
|
|
414
402
|
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
403
|
clearLastSaved(current);
|
|
417
|
-
// Session-only ask counter (like lastSavedArtifact): reset on every accepted transition.
|
|
418
404
|
askRoundsThisPhase = 0;
|
|
419
|
-
pendingHistoryReset = resetHistoryForPhase(system, phaseState, {
|
|
420
|
-
|
|
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)}` : ""})`;
|
|
405
|
+
pendingHistoryReset = resetHistoryForPhase(system, phaseState, { goal });
|
|
406
|
+
return `ok — phase advanced to '${outcome.phase}' (was '${current}'${summary ? `, summary: ${summary.slice(0, 80)}` : ""})`;
|
|
429
407
|
},
|
|
430
408
|
}
|
|
431
409
|
: {};
|
|
@@ -455,6 +433,18 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
455
433
|
const restoredSlots = input.resume && deps.runState && runId
|
|
456
434
|
? await readLibrarySidecars(deps.runState.cwd, deps.runState.dir, runId)
|
|
457
435
|
: [];
|
|
436
|
+
// Seed host-side idempotency from restored sidecars so re-load is a no-op.
|
|
437
|
+
const restoredPrefixes: string[] = [];
|
|
438
|
+
for (const slot of restoredSlots) {
|
|
439
|
+
if (!Array.isArray(slot.payload) || slot.payload.length === 0) continue;
|
|
440
|
+
const first = slot.payload[0];
|
|
441
|
+
if (first === null || typeof first !== "object") continue;
|
|
442
|
+
const path = typeof (first as { path?: unknown }).path === "string"
|
|
443
|
+
? (first as { path: string }).path
|
|
444
|
+
: "";
|
|
445
|
+
const m = path.match(/^(lib\/[^/]+\/)/);
|
|
446
|
+
if (m?.[1] !== undefined) restoredPrefixes.push(m[1]);
|
|
447
|
+
}
|
|
458
448
|
const libraryHandlers = deps.config.libraryLoader
|
|
459
449
|
? buildLibraryHandler({
|
|
460
450
|
cwd: runCwd,
|
|
@@ -462,6 +452,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
462
452
|
parentId: selfReportId,
|
|
463
453
|
signal: deps.signal,
|
|
464
454
|
startIndex: 1 + restoredSlots.reduce((m, s) => Math.max(m, s.index), 0),
|
|
455
|
+
loadedPrefixes: restoredPrefixes,
|
|
465
456
|
onLoaded: async (index, payload) => {
|
|
466
457
|
if (!persistOn || !runId || !deps.runState) return;
|
|
467
458
|
await writeContextSidecar(
|
|
@@ -480,6 +471,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
480
471
|
signal: deps.signal,
|
|
481
472
|
initTimeoutMs: deps.config.sandboxInitTimeoutMs,
|
|
482
473
|
maxPromptChars: deps.config.maxPromptChars,
|
|
474
|
+
// Pipeline at depth 0 is read-only: guard open() write modes in the worker.
|
|
475
|
+
readOnly: pipelineOn,
|
|
483
476
|
handlers: { ...llm, ...rlm, ...phaseHandlers, ...interactiveHandlers, ...libraryHandlers },
|
|
484
477
|
});
|
|
485
478
|
|
|
@@ -489,24 +482,23 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
489
482
|
if (input.resume) {
|
|
490
483
|
limits.addRaw(input.resume.usageSeed.costUsd, input.resume.usageSeed.inputTokens, input.resume.usageSeed.outputTokens);
|
|
491
484
|
best = input.resume.best;
|
|
492
|
-
editsAcc = [];
|
|
493
485
|
compactions = input.resume.compactions;
|
|
494
486
|
completedTurns = input.resume.completedTurns;
|
|
495
487
|
if (input.resume.phase) {
|
|
496
488
|
const resumePhase = input.resume.phase;
|
|
497
|
-
const artifacts: Partial<Record<Phase,
|
|
489
|
+
const artifacts: Partial<Record<Phase, ArtifactRef>> = {};
|
|
498
490
|
if (resumePhase.artifacts) {
|
|
499
491
|
for (const [k, v] of Object.entries(resumePhase.artifacts)) {
|
|
500
|
-
if (
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
492
|
+
if (v !== undefined && isPhase(k)) {
|
|
493
|
+
artifacts[k] = Object.freeze({
|
|
494
|
+
path: v.path,
|
|
495
|
+
status: v.superseded ? "superseded" as const : "active" as const,
|
|
496
|
+
});
|
|
505
497
|
}
|
|
506
498
|
}
|
|
507
499
|
}
|
|
508
500
|
phaseState = {
|
|
509
|
-
current: resumePhase.current
|
|
501
|
+
current: reconcilePhase(resumePhase.current),
|
|
510
502
|
advancedAt: resumePhase.advancedAt,
|
|
511
503
|
summary: resumePhase.summary,
|
|
512
504
|
artifacts,
|
|
@@ -514,8 +506,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
514
506
|
};
|
|
515
507
|
// lastSaved is session-only: never rehydrate from trail (would re-gate stale
|
|
516
508
|
// plan/validation after loop-back / mid-stage resume without a fresh save).
|
|
517
|
-
|
|
518
|
-
// askRoundsThisPhase is session-only (like
|
|
509
|
+
lastSaved = {};
|
|
510
|
+
// askRoundsThisPhase is session-only (like lastSaved): a resume mid-clarify
|
|
519
511
|
// restarts the interview count so the model must ask again in this process.
|
|
520
512
|
askRoundsThisPhase = 0;
|
|
521
513
|
}
|
|
@@ -541,13 +533,15 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
541
533
|
}
|
|
542
534
|
|
|
543
535
|
// Context: serialize ContextBundle to sandbox-ready JSON array, pass raw strings through.
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
536
|
+
// Resume: merge library sidecars into the single `context` list (no context_N slots).
|
|
537
|
+
let contextValue: unknown =
|
|
538
|
+
typeof input.context === "object" && input.context !== null && "files" in input.context
|
|
539
|
+
? serializeForSandbox(input.context as ContextBundle)
|
|
540
|
+
: input.context;
|
|
548
541
|
for (const slot of restoredSlots) {
|
|
549
|
-
|
|
542
|
+
contextValue = mergeLibraryIntoContext(contextValue, slot.payload);
|
|
550
543
|
}
|
|
544
|
+
await sandbox.loadContext(contextValue);
|
|
551
545
|
if (input.resume?.snapshotTurn !== undefined && deps.runState && runId && sessionNonce) // R-C1: restore only for same-session (sessionNonce present)
|
|
552
546
|
await sandbox.restore(snapshotPath(deps.runState.cwd, deps.runState.dir, runId, input.resume.snapshotTurn), sessionNonce);
|
|
553
547
|
for (let i = startTurn; i < deps.config.maxIterations; i++) {
|
|
@@ -629,14 +623,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
629
623
|
if (answerContent) best = answerContent;
|
|
630
624
|
else if (!best && turn.response.trim()) best = turn.response;
|
|
631
625
|
completedTurns = i + 1;
|
|
632
|
-
const proposedEdits = collectEdits(turn.results);
|
|
633
|
-
if (proposedEdits.length > 0) editsAcc = proposedEdits;
|
|
634
626
|
const final = finalAnswerOf(turn.results);
|
|
635
627
|
if (final != null) {
|
|
636
628
|
// Validate-phase finalize: measure THIS turn's validation save only (lastSaved),
|
|
637
629
|
// never fall back to phaseState.artifacts (stale after a prior loop).
|
|
638
630
|
if (pipelineOn && phaseState?.current === "validate") {
|
|
639
|
-
const vPath =
|
|
631
|
+
const vPath = lastSaved.validate?.path;
|
|
640
632
|
if (vPath === undefined) {
|
|
641
633
|
// Reject finalize — push error into next turn.
|
|
642
634
|
history.push({ role: "assistant", content: turn.response });
|
|
@@ -669,14 +661,20 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
669
661
|
deps.config.maxBackwardJumps,
|
|
670
662
|
);
|
|
671
663
|
if (route.kind === "loop-back") {
|
|
672
|
-
// Keep
|
|
673
|
-
//
|
|
674
|
-
|
|
675
|
-
const nextArtifacts: Partial<Record<Phase,
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
validate: vPath,
|
|
664
|
+
// Keep all prior artifacts; mark blueprint as superseded by this validation.
|
|
665
|
+
// lastSaved is still cleared so the gate must see a genuinely FRESH plan.
|
|
666
|
+
const prior = phaseState.artifacts.blueprint;
|
|
667
|
+
const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = {
|
|
668
|
+
...phaseState.artifacts,
|
|
669
|
+
validate: Object.freeze({ path: vPath, status: "active" }),
|
|
679
670
|
};
|
|
671
|
+
if (prior !== undefined) {
|
|
672
|
+
nextArtifacts.blueprint = Object.freeze({
|
|
673
|
+
path: prior.path,
|
|
674
|
+
status: "superseded",
|
|
675
|
+
supersededBy: vPath,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
680
678
|
phaseState = {
|
|
681
679
|
current: "blueprint",
|
|
682
680
|
advancedAt: completedTurns,
|
|
@@ -684,7 +682,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
684
682
|
artifacts: nextArtifacts,
|
|
685
683
|
backwardJumps: phaseState.backwardJumps + 1,
|
|
686
684
|
};
|
|
687
|
-
await persistPhaseRow(phaseState, vPath, "validate", gate.value);
|
|
685
|
+
await persistPhaseRow(phaseState, vPath, "validate", gate.value, prior?.path);
|
|
688
686
|
clearLastSaved("blueprint", "validate");
|
|
689
687
|
askRoundsThisPhase = 0;
|
|
690
688
|
history = resetHistoryForPhase(system, phaseState, { goal, validation });
|
|
@@ -694,14 +692,14 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
694
692
|
}
|
|
695
693
|
if (route.kind === "halt") {
|
|
696
694
|
const report = `${route.reason}\n\n${final}`;
|
|
697
|
-
const halted = result(report, i + 1, limits
|
|
695
|
+
const halted = result(report, i + 1, limits);
|
|
698
696
|
await recordTerminal("completed", halted);
|
|
699
697
|
lastAnswer = halted.answer;
|
|
700
698
|
return halted;
|
|
701
699
|
}
|
|
702
700
|
// route.kind === "done" — accept final answer
|
|
703
701
|
}
|
|
704
|
-
const done = result(final, i + 1, limits
|
|
702
|
+
const done = result(final, i + 1, limits);
|
|
705
703
|
await recordTerminal("completed", done);
|
|
706
704
|
lastAnswer = done.answer;
|
|
707
705
|
return done;
|
|
@@ -733,7 +731,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
733
731
|
// Trail keeps the real REPL output even when history was reset (issue #9).
|
|
734
732
|
replOutputs: turnReplOutputs || undefined,
|
|
735
733
|
answerContent: answerContent || undefined,
|
|
736
|
-
edits: proposedEdits.length > 0 ? proposedEdits : undefined,
|
|
737
734
|
error: turnHadError(turn.results),
|
|
738
735
|
usage: { costUsd: turn.usage.cost.total, inputTokens: turn.usage.input, outputTokens: turn.usage.output }, // B2: Usage has .input/.output, not .inputTokens/.outputTokens
|
|
739
736
|
cumulativeDurationMs: limits.usage().durationMs, // B3: required by TurnRow, seeds LimitGuard clock on resume (CA)
|
|
@@ -744,21 +741,21 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
744
741
|
}
|
|
745
742
|
}
|
|
746
743
|
if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
|
|
747
|
-
const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits
|
|
744
|
+
const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
|
|
748
745
|
await recordTerminal("finalized", finalized);
|
|
749
746
|
lastAnswer = finalized.answer;
|
|
750
747
|
return finalized;
|
|
751
748
|
} catch (err) {
|
|
752
749
|
// Abort is a user action — resolve with the best partial, not an error.
|
|
753
750
|
if (deps.signal?.aborted) {
|
|
754
|
-
const aborted = result(best.trim() || "(aborted)", completedTurns, limits
|
|
751
|
+
const aborted = result(best.trim() || "(aborted)", completedTurns, limits);
|
|
755
752
|
await recordTerminal("aborted", aborted);
|
|
756
753
|
lastAnswer = aborted.answer;
|
|
757
754
|
return aborted;
|
|
758
755
|
}
|
|
759
756
|
if (err instanceof LimitError) {
|
|
760
757
|
nodeStatus = "error";
|
|
761
|
-
const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits
|
|
758
|
+
const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits);
|
|
762
759
|
await recordTerminal("stopped", stopped);
|
|
763
760
|
lastAnswer = stopped.answer;
|
|
764
761
|
return stopped;
|
|
@@ -775,7 +772,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
775
772
|
});
|
|
776
773
|
} else {
|
|
777
774
|
if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
|
|
778
|
-
emitter.emitEdits(editsAcc.length > 0 ? editsAcc : []);
|
|
779
775
|
emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
|
|
780
776
|
}
|
|
781
777
|
await sandbox?.dispose();
|
|
@@ -784,9 +780,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
784
780
|
return run;
|
|
785
781
|
}
|
|
786
782
|
|
|
787
|
-
function result(answer: string, iterations: number, limits: LimitGuard
|
|
783
|
+
function result(answer: string, iterations: number, limits: LimitGuard): RlmResult {
|
|
788
784
|
const u = limits.usage();
|
|
789
|
-
return { answer,
|
|
785
|
+
return { answer, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
|
|
790
786
|
}
|
|
791
787
|
|
|
792
788
|
/** Out of turns: ask the model for its best final answer (plain text). */
|