@hicaru/pi-rlm 0.1.6 → 0.1.8
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 +35 -1
- package/package.json +1 -1
- package/src/bridge/library.ts +77 -0
- package/src/bridge/rlm-query.ts +58 -12
- package/src/config/defaults.ts +2 -0
- package/src/config/settings.ts +4 -0
- package/src/context/library-context.ts +79 -0
- package/src/core/artifacts.ts +88 -0
- package/src/core/engine.ts +432 -35
- package/src/core/gates.ts +272 -0
- package/src/core/iteration.ts +7 -2
- package/src/core/pipeline.ts +170 -27
- package/src/core/types.ts +4 -0
- package/src/index.ts +6 -1
- package/src/prompts/phases.ts +125 -0
- package/src/prompts/system.ts +40 -9
- package/src/prompts/user.ts +12 -4
- package/src/sandbox/protocol.ts +25 -2
- package/src/sandbox/sandbox.ts +42 -1
- package/src/sandbox/worker.py +42 -5
- package/src/state/index.ts +2 -1
- package/src/state/paths.ts +4 -2
- package/src/state/reads.ts +31 -2
- package/src/state/resume.ts +19 -1
- package/src/state/rows.ts +6 -0
- package/src/state/writes.ts +5 -3
- package/src/text/edits.ts +148 -0
- package/src/tool/apply-edits-tool.ts +224 -55
- package/src/tool/repl-tool.ts +23 -2
- package/src/tool/rlm-tool.ts +0 -1
- package/src/ui/config-panel.ts +8 -1
package/src/core/engine.ts
CHANGED
|
@@ -5,21 +5,40 @@
|
|
|
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
|
+
* serial implement fanout via child RLMs, history reset at phase boundaries, and
|
|
11
|
+
* measured validate→blueprint corrective routing.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
11
15
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
12
16
|
import { buildInteractiveHandlers } from "../bridge/interactive.ts";
|
|
17
|
+
import { buildLibraryHandler } from "../bridge/library.ts";
|
|
13
18
|
import { createLlmBridge } from "../bridge/llm-query.ts";
|
|
14
19
|
import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
15
20
|
import { createRlmHandlers } from "../bridge/rlm-query.ts";
|
|
16
21
|
import { resolveModelId } from "../config/settings.ts";
|
|
17
22
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
18
23
|
import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
|
|
24
|
+
import { buildImplementPhasePrompt, phaseGuidance } from "../prompts/phases.ts";
|
|
19
25
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
20
26
|
import { PythonSandbox } from "../sandbox/sandbox.ts";
|
|
21
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
advancePhase as validatePhaseTransition,
|
|
29
|
+
initialPhaseState,
|
|
30
|
+
phaseGatePrompt,
|
|
31
|
+
routeAfterValidate,
|
|
32
|
+
stageForArtifactKind,
|
|
33
|
+
STAGES,
|
|
34
|
+
type Phase,
|
|
35
|
+
type PhaseState,
|
|
36
|
+
type StageGateData,
|
|
37
|
+
} from "./pipeline.ts";
|
|
38
|
+
import type { PlanGateData, ValidationGateData } from "./gates.ts";
|
|
39
|
+
import { captureGoal, readArtifact, saveArtifact, type GoalCapture } from "./artifacts.ts";
|
|
22
40
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
41
|
+
import { applyProposedEdits } from "../text/edits.ts";
|
|
23
42
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
24
43
|
import { collectEdits, finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
25
44
|
import { compactHistory, shouldCompact } from "./compaction.ts";
|
|
@@ -28,12 +47,20 @@ import { runTurn } from "./iteration.ts";
|
|
|
28
47
|
import { type Limits, LimitError, LimitGuard } from "./limits.ts";
|
|
29
48
|
import type { InteractiveDeps, RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
|
|
30
49
|
import { randomUUID } from "node:crypto";
|
|
31
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
appendRow,
|
|
52
|
+
appendTodoRow,
|
|
53
|
+
generateRunId,
|
|
54
|
+
pruneRuns,
|
|
55
|
+
readLibrarySidecars,
|
|
56
|
+
snapshotPath,
|
|
57
|
+
writeContextSidecar,
|
|
58
|
+
} from "../state/index.ts";
|
|
32
59
|
import { STATE_SCHEMA_VERSION } from "../state/rows.ts";
|
|
33
60
|
import type { PhaseRow, RunHeader } from "../state/rows.ts";
|
|
34
61
|
import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
|
|
35
62
|
import type { ProposedEdit } from "../sandbox/protocol.ts";
|
|
36
|
-
import { formatError } from "../util/errors.ts";
|
|
63
|
+
import { formatError, isErrorText } from "../util/errors.ts";
|
|
37
64
|
|
|
38
65
|
|
|
39
66
|
export interface EngineDeps extends InteractiveDeps {
|
|
@@ -49,6 +76,56 @@ export interface EngineDeps extends InteractiveDeps {
|
|
|
49
76
|
readonly onUsage?: (usage: Usage, role: "root" | "sub") => void;
|
|
50
77
|
/** Run-state persistence handle. undefined ⇒ persistence off. */
|
|
51
78
|
readonly runState?: { readonly cwd: string; readonly dir: string; readonly snapshot: boolean };
|
|
79
|
+
/** Test-only: override model completion (scripted multi-turn responses). */
|
|
80
|
+
readonly complete?: import("./iteration.ts").CompleteFn;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Optional payload for a fresh-session history reset at a phase boundary. */
|
|
84
|
+
export interface PhaseHistoryOptions {
|
|
85
|
+
readonly goal?: GoalCapture;
|
|
86
|
+
readonly validation?: ValidationGateData;
|
|
87
|
+
/** Fanout summary embedded so implement-exit result survives the history wipe. */
|
|
88
|
+
readonly implementSummary?: string;
|
|
89
|
+
/** Engine notice folded into the first user message (no console I/O). */
|
|
90
|
+
readonly notice?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fresh-session policy: at each phase boundary the conversation is replaced;
|
|
95
|
+
* artifacts (paths) are the only channel. REPL variables persist — the transition
|
|
96
|
+
* message tells the model context survives in the sandbox, not in chat.
|
|
97
|
+
*/
|
|
98
|
+
export function resetHistoryForPhase(
|
|
99
|
+
system: string,
|
|
100
|
+
state: PhaseState,
|
|
101
|
+
options: PhaseHistoryOptions = {},
|
|
102
|
+
): ChatMsg[] {
|
|
103
|
+
const { goal, validation, implementSummary, notice } = options;
|
|
104
|
+
const parts: string[] = [
|
|
105
|
+
`You are entering the '${state.current}' phase.`,
|
|
106
|
+
];
|
|
107
|
+
if (notice) parts.push(notice);
|
|
108
|
+
if (goal) {
|
|
109
|
+
parts.push(`The user's verbatim brief: read ${goal.goalPath} from the REPL (open()).`);
|
|
110
|
+
parts.push(`Pre-run dirty baseline (exclude from delta judgment): ${goal.baselinePath}`);
|
|
111
|
+
}
|
|
112
|
+
for (const [p, path] of Object.entries(state.artifacts)) {
|
|
113
|
+
if (path !== undefined) parts.push(`Artifact from '${p}': ${path}`);
|
|
114
|
+
}
|
|
115
|
+
if (validation) {
|
|
116
|
+
parts.push(
|
|
117
|
+
`Previous validation found ${validation.blockersCount} blocker(s) — read the validation artifact and address every blocker in the revised plan.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (implementSummary) {
|
|
121
|
+
parts.push("Implement fanout result:", implementSummary);
|
|
122
|
+
}
|
|
123
|
+
parts.push(phaseGuidance(state.current));
|
|
124
|
+
parts.push("Your REPL variables persist; the chat history was reset to keep your window small.");
|
|
125
|
+
return [
|
|
126
|
+
{ role: "system", content: system },
|
|
127
|
+
{ role: "user", content: parts.join("\n") },
|
|
128
|
+
];
|
|
52
129
|
}
|
|
53
130
|
|
|
54
131
|
/** Build a `runRlm` bound to the given deps. The returned function is reused for recursion. */
|
|
@@ -57,6 +134,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
57
134
|
const run: RunRlm = async (input: RlmInput): Promise<RlmResult> => {
|
|
58
135
|
const nowIso = (): string => new Date().toISOString(); // local helper — 4 call sites below
|
|
59
136
|
const persist = input.depth === 0 && deps.runState !== undefined;
|
|
137
|
+
const runCwd = deps.runState?.cwd ?? process.cwd();
|
|
60
138
|
// Compute runId early for run-state correlation on resume.
|
|
61
139
|
const runId = persist
|
|
62
140
|
? (input.resume ? input.resume.header.runId : generateRunId())
|
|
@@ -139,6 +217,11 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
139
217
|
let completedTurns = 0;
|
|
140
218
|
let editsAcc: ProposedEdit[] = [];
|
|
141
219
|
let phaseState: PhaseState | undefined;
|
|
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;
|
|
142
225
|
let nodeStatus: "done" | "error" = "done";
|
|
143
226
|
let persistOn = persist;
|
|
144
227
|
if (persist && deps.runState && !input.resume && runId) {
|
|
@@ -167,26 +250,195 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
167
250
|
});
|
|
168
251
|
};
|
|
169
252
|
|
|
253
|
+
const persistPhaseRow = async (
|
|
254
|
+
state: PhaseState,
|
|
255
|
+
artifactPath: string | undefined,
|
|
256
|
+
artifactPhase: Phase | undefined,
|
|
257
|
+
gateData: StageGateData | undefined,
|
|
258
|
+
): Promise<void> => {
|
|
259
|
+
if (!persistOn || !runId || !deps.runState) return;
|
|
260
|
+
const row: PhaseRow = {
|
|
261
|
+
kind: "phase",
|
|
262
|
+
turn: completedTurns + 1,
|
|
263
|
+
ts: nowIso(),
|
|
264
|
+
phase: state.current,
|
|
265
|
+
summary: state.summary,
|
|
266
|
+
artifactPath,
|
|
267
|
+
artifactPhase,
|
|
268
|
+
blockersCount: gateData?.kind === "validation" ? gateData.validation.blockersCount : undefined,
|
|
269
|
+
backwardJumps: state.backwardJumps,
|
|
270
|
+
};
|
|
271
|
+
const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, row);
|
|
272
|
+
if (!ok) persistOn = false;
|
|
273
|
+
};
|
|
274
|
+
|
|
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
|
+
|
|
170
324
|
try {
|
|
171
|
-
const
|
|
325
|
+
const pipelineOn = input.depth === 0 && deps.config.pipeline;
|
|
326
|
+
const meta = {
|
|
327
|
+
contextType: contextTypeLabel(input.context),
|
|
328
|
+
contextChars: contextLength(input.context),
|
|
329
|
+
contextStats: contextSizeStats(input.context),
|
|
330
|
+
rootPrompt: input.rootPrompt || undefined,
|
|
331
|
+
};
|
|
332
|
+
const system = buildRlmSystemPrompt(meta, {
|
|
333
|
+
orchestrator: deps.config.orchestrator,
|
|
334
|
+
recursion: input.depth + 1 < deps.config.maxDepth,
|
|
335
|
+
askUserQuestion: deps.config.askUserQuestion && input.depth === 0,
|
|
336
|
+
todo: deps.config.todo,
|
|
337
|
+
pipeline: deps.config.pipeline && input.depth === 0,
|
|
338
|
+
maxPromptChars: deps.config.maxPromptChars,
|
|
339
|
+
libraryLoader: deps.config.libraryLoader,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const phaseHandlers = pipelineOn
|
|
172
343
|
? {
|
|
173
|
-
|
|
174
|
-
const
|
|
344
|
+
saveArtifact: async (kind: string, content: string): Promise<string> => {
|
|
345
|
+
const stage = stageForArtifactKind(kind);
|
|
346
|
+
if (stage === undefined) {
|
|
347
|
+
return formatError(`unknown artifact kind '${kind}' (valid: clarification, research, plan, validation)`);
|
|
348
|
+
}
|
|
349
|
+
const current = phaseState?.current ?? "clarify";
|
|
350
|
+
if (stage.phase !== current) {
|
|
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);
|
|
175
363
|
if (!outcome.ok) return formatError(outcome.error);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
);
|
|
182
382
|
}
|
|
183
|
-
|
|
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}'`;
|
|
184
428
|
return `ok — phase advanced to '${outcome.phase}' (${prevLabel}${summary ? `, summary: ${summary.slice(0, 80)}` : ""})`;
|
|
185
429
|
},
|
|
186
430
|
}
|
|
187
431
|
: {};
|
|
432
|
+
const baseAsk = deps.config.askUserQuestion ? deps.onAskUserQuestion : undefined;
|
|
188
433
|
const interactiveHandlers = buildInteractiveHandlers({
|
|
189
|
-
onAskUserQuestion:
|
|
434
|
+
onAskUserQuestion: baseAsk
|
|
435
|
+
? async (questions) => {
|
|
436
|
+
const answers = await baseAsk(questions);
|
|
437
|
+
// Count only successfully serviced root-depth rounds (handler already rejects depth>0).
|
|
438
|
+
askRoundsThisPhase++;
|
|
439
|
+
return answers;
|
|
440
|
+
}
|
|
441
|
+
: undefined,
|
|
190
442
|
onTodo: deps.config.todo ? deps.onTodo : undefined,
|
|
191
443
|
onTodoRow: async (action, params, todoResult) => {
|
|
192
444
|
if (!persistOn || !runId || !deps.runState) return;
|
|
@@ -200,6 +452,26 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
200
452
|
parentId: selfReportId,
|
|
201
453
|
});
|
|
202
454
|
|
|
455
|
+
const restoredSlots = input.resume && deps.runState && runId
|
|
456
|
+
? await readLibrarySidecars(deps.runState.cwd, deps.runState.dir, runId)
|
|
457
|
+
: [];
|
|
458
|
+
const libraryHandlers = deps.config.libraryLoader
|
|
459
|
+
? buildLibraryHandler({
|
|
460
|
+
cwd: runCwd,
|
|
461
|
+
emitter,
|
|
462
|
+
parentId: selfReportId,
|
|
463
|
+
signal: deps.signal,
|
|
464
|
+
startIndex: 1 + restoredSlots.reduce((m, s) => Math.max(m, s.index), 0),
|
|
465
|
+
onLoaded: async (index, payload) => {
|
|
466
|
+
if (!persistOn || !runId || !deps.runState) return;
|
|
467
|
+
await writeContextSidecar(
|
|
468
|
+
deps.runState.cwd, deps.runState.dir, runId,
|
|
469
|
+
payload, typeof payload !== "string", index,
|
|
470
|
+
);
|
|
471
|
+
},
|
|
472
|
+
}).handlers
|
|
473
|
+
: {};
|
|
474
|
+
|
|
203
475
|
sandbox = await PythonSandbox.spawn({
|
|
204
476
|
depth: input.depth,
|
|
205
477
|
execTimeoutS: deps.config.execTimeoutS,
|
|
@@ -208,23 +480,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
208
480
|
signal: deps.signal,
|
|
209
481
|
initTimeoutMs: deps.config.sandboxInitTimeoutMs,
|
|
210
482
|
maxPromptChars: deps.config.maxPromptChars,
|
|
211
|
-
handlers: { ...llm, ...rlm, ...phaseHandlers, ...interactiveHandlers },
|
|
483
|
+
handlers: { ...llm, ...rlm, ...phaseHandlers, ...interactiveHandlers, ...libraryHandlers },
|
|
212
484
|
});
|
|
213
485
|
|
|
214
|
-
const meta = {
|
|
215
|
-
contextType: contextTypeLabel(input.context),
|
|
216
|
-
contextChars: contextLength(input.context),
|
|
217
|
-
contextStats: contextSizeStats(input.context),
|
|
218
|
-
rootPrompt: input.rootPrompt || undefined,
|
|
219
|
-
};
|
|
220
|
-
const system = buildRlmSystemPrompt(meta, {
|
|
221
|
-
orchestrator: deps.config.orchestrator,
|
|
222
|
-
recursion: input.depth + 1 < deps.config.maxDepth,
|
|
223
|
-
askUserQuestion: deps.config.askUserQuestion && input.depth === 0,
|
|
224
|
-
todo: deps.config.todo,
|
|
225
|
-
pipeline: deps.config.pipeline && input.depth === 0,
|
|
226
|
-
maxPromptChars: deps.config.maxPromptChars,
|
|
227
|
-
});
|
|
228
486
|
let history: ChatMsg[] = input.resume ? input.resume.history : [{ role: "system", content: system }];
|
|
229
487
|
let pendingReplOutputs: string | undefined = input.resume?.pendingReplOutputs;
|
|
230
488
|
const startTurn = input.resume?.completedTurns ?? 0;
|
|
@@ -236,8 +494,50 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
236
494
|
completedTurns = input.resume.completedTurns;
|
|
237
495
|
if (input.resume.phase) {
|
|
238
496
|
const resumePhase = input.resume.phase;
|
|
239
|
-
|
|
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
|
+
}
|
|
522
|
+
} else if (pipelineOn) {
|
|
523
|
+
// Goal capture (script, no LLM) + seed phase state + fresh history.
|
|
524
|
+
const captured = captureGoal(runCwd, input.rootPrompt);
|
|
525
|
+
let goalNotice: string | undefined;
|
|
526
|
+
if (captured.ok) {
|
|
527
|
+
goal = captured.value;
|
|
528
|
+
} else {
|
|
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.`;
|
|
240
531
|
}
|
|
532
|
+
// Clarify only when interviews are enabled AND the host wired a callback.
|
|
533
|
+
// Config alone is not enough: without onAskUserQuestion every ask throws and
|
|
534
|
+
// the run would burn maxIterations stuck at clarify (askRounds stays 0).
|
|
535
|
+
const startPhase =
|
|
536
|
+
deps.config.askUserQuestion && deps.onAskUserQuestion !== undefined
|
|
537
|
+
? "clarify"
|
|
538
|
+
: "research";
|
|
539
|
+
phaseState = initialPhaseState(0, startPhase);
|
|
540
|
+
history = resetHistoryForPhase(system, phaseState, { goal, notice: goalNotice });
|
|
241
541
|
}
|
|
242
542
|
|
|
243
543
|
// Context: serialize ContextBundle to sandbox-ready JSON array, pass raw strings through.
|
|
@@ -245,6 +545,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
245
545
|
? serializeForSandbox(input.context as ContextBundle)
|
|
246
546
|
: input.context;
|
|
247
547
|
await sandbox.loadContext(contextValue);
|
|
548
|
+
for (const slot of restoredSlots) {
|
|
549
|
+
await sandbox.loadContext(slot.payload, slot.index); // re-injects context_N for resumed runs
|
|
550
|
+
}
|
|
248
551
|
if (input.resume?.snapshotTurn !== undefined && deps.runState && runId && sessionNonce) // R-C1: restore only for same-session (sessionNonce present)
|
|
249
552
|
await sandbox.restore(snapshotPath(deps.runState.cwd, deps.runState.dir, runId, input.resume.snapshotTurn), sessionNonce);
|
|
250
553
|
for (let i = startTurn; i < deps.config.maxIterations; i++) {
|
|
@@ -252,6 +555,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
252
555
|
if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
|
|
253
556
|
else emitter.emitTurn(i + 1, deps.config.maxIterations);
|
|
254
557
|
|
|
558
|
+
// Apply deferred history reset from a prior advance_phase (fresh session policy).
|
|
559
|
+
if (pendingHistoryReset !== undefined) {
|
|
560
|
+
history = pendingHistoryReset;
|
|
561
|
+
pendingHistoryReset = undefined;
|
|
562
|
+
pendingReplOutputs = undefined;
|
|
563
|
+
}
|
|
564
|
+
|
|
255
565
|
if (deps.config.compaction) {
|
|
256
566
|
const compactionDeps = {
|
|
257
567
|
// Summarisation is done by the cheap worker model; the threshold stays on the
|
|
@@ -286,7 +596,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
286
596
|
|
|
287
597
|
const gateMsg = deps.config.pipeline ? phaseGatePrompt(phaseState, completedTurns) : undefined;
|
|
288
598
|
const gateUserMsg = gateMsg ? `[${new Date().toISOString()}] ${gateMsg}` : undefined;
|
|
289
|
-
|
|
599
|
+
// Phase guidance lives only in resetHistoryForPhase (fresh session) — do not re-inject
|
|
600
|
+
// into every turn prompt (avoids duplication on turn 1 and dead post-transition flags).
|
|
601
|
+
appendUserMessage(
|
|
602
|
+
history,
|
|
603
|
+
buildTurnPrompt(i, deps.config.maxIterations, gateUserMsg),
|
|
604
|
+
);
|
|
290
605
|
|
|
291
606
|
// rootSampling fields win; smartReasoning is the default reasoning when not overridden.
|
|
292
607
|
const rootSampling: Sampling = {
|
|
@@ -298,6 +613,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
298
613
|
registry: deps.registry,
|
|
299
614
|
sampling: rootSampling,
|
|
300
615
|
signal: deps.signal,
|
|
616
|
+
complete: deps.complete,
|
|
301
617
|
});
|
|
302
618
|
const allBlocks = turn.blocks.length > 0
|
|
303
619
|
? turn.blocks.map((b) => previewText(b, 400)).join("\n")
|
|
@@ -317,6 +633,74 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
317
633
|
if (proposedEdits.length > 0) editsAcc = proposedEdits;
|
|
318
634
|
const final = finalAnswerOf(turn.results);
|
|
319
635
|
if (final != null) {
|
|
636
|
+
// Validate-phase finalize: measure THIS turn's validation save only (lastSaved),
|
|
637
|
+
// never fall back to phaseState.artifacts (stale after a prior loop).
|
|
638
|
+
if (pipelineOn && phaseState?.current === "validate") {
|
|
639
|
+
const vPath = lastSavedArtifact.validate;
|
|
640
|
+
if (vPath === undefined) {
|
|
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") {
|
|
661
|
+
history.push({ role: "assistant", content: turn.response });
|
|
662
|
+
pendingReplOutputs = formatError("internal: validate gate did not return validation data");
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const validation = gate.value.validation;
|
|
666
|
+
const route = routeAfterValidate(
|
|
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 });
|
|
691
|
+
pendingReplOutputs = undefined;
|
|
692
|
+
pendingHistoryReset = undefined;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
if (route.kind === "halt") {
|
|
696
|
+
const report = `${route.reason}\n\n${final}`;
|
|
697
|
+
const halted = result(report, i + 1, limits, editsAcc);
|
|
698
|
+
await recordTerminal("completed", halted);
|
|
699
|
+
lastAnswer = halted.answer;
|
|
700
|
+
return halted;
|
|
701
|
+
}
|
|
702
|
+
// route.kind === "done" — accept final answer
|
|
703
|
+
}
|
|
320
704
|
const done = result(final, i + 1, limits, editsAcc);
|
|
321
705
|
await recordTerminal("completed", done);
|
|
322
706
|
lastAnswer = done.answer;
|
|
@@ -324,9 +708,19 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
324
708
|
}
|
|
325
709
|
|
|
326
710
|
limits.observe(turnHadError(turn.results));
|
|
327
|
-
|
|
711
|
+
// Always capture this turn's REPL outputs for the JSONL trail (fidelity).
|
|
328
712
|
const turnReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
|
|
329
|
-
|
|
713
|
+
// If advance_phase scheduled a history reset, apply it now (do not pollute fresh history).
|
|
714
|
+
if (pendingHistoryReset !== undefined) {
|
|
715
|
+
history = pendingHistoryReset;
|
|
716
|
+
pendingHistoryReset = undefined;
|
|
717
|
+
// Fanout/advance result is already embedded in the reset user message — do not
|
|
718
|
+
// also append raw REPL stdout as a next-turn user message.
|
|
719
|
+
pendingReplOutputs = undefined;
|
|
720
|
+
} else {
|
|
721
|
+
history.push({ role: "assistant", content: turn.response });
|
|
722
|
+
pendingReplOutputs = turnReplOutputs;
|
|
723
|
+
}
|
|
330
724
|
|
|
331
725
|
if (persistOn && runId && deps.runState) {
|
|
332
726
|
const pklPath = snapshotPath(deps.runState.cwd, deps.runState.dir, runId, i + 1);
|
|
@@ -335,7 +729,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
335
729
|
: false;
|
|
336
730
|
const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
|
|
337
731
|
kind: "turn", turn: i + 1, ts: nowIso(),
|
|
338
|
-
response: turn.response,
|
|
732
|
+
response: turn.response,
|
|
733
|
+
// Trail keeps the real REPL output even when history was reset (issue #9).
|
|
734
|
+
replOutputs: turnReplOutputs || undefined,
|
|
339
735
|
answerContent: answerContent || undefined,
|
|
340
736
|
edits: proposedEdits.length > 0 ? proposedEdits : undefined,
|
|
341
737
|
error: turnHadError(turn.results),
|
|
@@ -397,7 +793,8 @@ function result(answer: string, iterations: number, limits: LimitGuard, edits: P
|
|
|
397
793
|
async function finalize(history: ChatMsg[], model: Model<Api>, deps: EngineDeps, limits: LimitGuard): Promise<string> {
|
|
398
794
|
const finalHistory = [...history];
|
|
399
795
|
appendUserMessage(finalHistory, FINALIZE_PROMPT);
|
|
400
|
-
const
|
|
796
|
+
const complete = deps.complete ?? modelComplete;
|
|
797
|
+
const { text, usage } = await complete(finalHistory, {
|
|
401
798
|
model,
|
|
402
799
|
registry: deps.registry,
|
|
403
800
|
reasoning: deps.config.smartReasoning,
|