@hicaru/pi-rlm 0.1.7 → 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 +41 -4
- package/package.json +2 -1
- package/src/bridge/library.ts +155 -0
- package/src/bridge/llm-query.ts +1 -0
- package/src/bridge/rlm-query.ts +56 -12
- package/src/config/defaults.ts +2 -0
- package/src/config/settings.ts +4 -0
- package/src/context/library-context.ts +266 -0
- package/src/context/repomix-context.ts +2 -48
- package/src/core/answer.ts +1 -10
- package/src/core/artifacts.ts +88 -0
- package/src/core/critique.ts +92 -0
- package/src/core/engine.ts +446 -53
- package/src/core/gates.ts +301 -0
- package/src/core/iteration.ts +7 -2
- package/src/core/pipeline.ts +196 -28
- package/src/core/types.ts +5 -3
- package/src/index.ts +3 -6
- package/src/mode/native-guards.ts +2 -2
- package/src/prompts/phases.ts +104 -0
- package/src/prompts/system.ts +59 -16
- package/src/prompts/user.ts +12 -4
- package/src/sandbox/protocol.ts +29 -11
- package/src/sandbox/sandbox.ts +77 -2
- package/src/sandbox/worker.py +215 -46
- 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 +31 -6
- package/src/state/rows.ts +8 -2
- package/src/state/writes.ts +5 -3
- package/src/text/tokens.ts +7 -1
- package/src/tool/repl-details.ts +2 -3
- package/src/tool/repl-tool.ts +52 -57
- 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 -8
- package/src/tool/subcall-store.ts +2 -0
- package/src/ui/config-panel.ts +8 -1
- package/src/registry/edit-registry.ts +0 -22
- package/src/text/edits.ts +0 -16
- package/src/tool/apply-edits-tool.ts +0 -288
package/src/core/engine.ts
CHANGED
|
@@ -5,34 +5,67 @@
|
|
|
5
5
|
* services `llm_query`/`rlm_query` via the bridges, and stops when the model submits an answer
|
|
6
6
|
* or a limit/turn cap is hit. Recursion is wired by giving the sandbox rlm handlers that call
|
|
7
7
|
* back into `runRlm` at depth+1. Used for recursion and for headless/automation runs.
|
|
8
|
+
*
|
|
9
|
+
* When `config.pipeline` is on at depth 0: goal capture, artifact-gated advance_phase,
|
|
10
|
+
* history reset at phase boundaries, and measured validate→blueprint corrective routing.
|
|
11
|
+
* The pipeline is read-only by design: it produces a validated plan; accidental
|
|
12
|
+
* sandbox writes are blocked (steering, not a hard security boundary).
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
11
16
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
12
17
|
import { buildInteractiveHandlers } from "../bridge/interactive.ts";
|
|
18
|
+
import { buildLibraryHandler } from "../bridge/library.ts";
|
|
19
|
+
import { mergeLibraryIntoContext } from "../context/library-context.ts";
|
|
13
20
|
import { createLlmBridge } from "../bridge/llm-query.ts";
|
|
14
21
|
import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
15
22
|
import { createRlmHandlers } from "../bridge/rlm-query.ts";
|
|
16
23
|
import { resolveModelId } from "../config/settings.ts";
|
|
17
24
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
18
25
|
import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
|
|
26
|
+
import { phaseGuidance } from "../prompts/phases.ts";
|
|
19
27
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
20
28
|
import { PythonSandbox } from "../sandbox/sandbox.ts";
|
|
21
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
advancePhase as validatePhaseTransition,
|
|
31
|
+
initialPhaseState,
|
|
32
|
+
isPhase,
|
|
33
|
+
phaseGatePrompt,
|
|
34
|
+
PHASES,
|
|
35
|
+
reconcilePhase,
|
|
36
|
+
routeAfterValidate,
|
|
37
|
+
stageForArtifactKind,
|
|
38
|
+
STAGES,
|
|
39
|
+
type ArtifactRef,
|
|
40
|
+
type Phase,
|
|
41
|
+
type PhaseState,
|
|
42
|
+
type SavedArtifact,
|
|
43
|
+
type StageGateData,
|
|
44
|
+
} from "./pipeline.ts";
|
|
45
|
+
import { critiqueArtifact, formatCritique } from "./critique.ts";
|
|
46
|
+
import type { ValidationGateData } from "./gates.ts";
|
|
47
|
+
import { captureGoal, readArtifact, saveArtifact, type GoalCapture } from "./artifacts.ts";
|
|
22
48
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
23
49
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
24
|
-
import {
|
|
50
|
+
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
25
51
|
import { compactHistory, shouldCompact } from "./compaction.ts";
|
|
26
52
|
import { appendUserMessage } from "./history.ts";
|
|
27
53
|
import { runTurn } from "./iteration.ts";
|
|
28
54
|
import { type Limits, LimitError, LimitGuard } from "./limits.ts";
|
|
29
55
|
import type { InteractiveDeps, RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
|
|
30
56
|
import { randomUUID } from "node:crypto";
|
|
31
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
appendRow,
|
|
59
|
+
appendTodoRow,
|
|
60
|
+
generateRunId,
|
|
61
|
+
pruneRuns,
|
|
62
|
+
readLibrarySidecars,
|
|
63
|
+
snapshotPath,
|
|
64
|
+
writeContextSidecar,
|
|
65
|
+
} from "../state/index.ts";
|
|
32
66
|
import { STATE_SCHEMA_VERSION } from "../state/rows.ts";
|
|
33
67
|
import type { PhaseRow, RunHeader } from "../state/rows.ts";
|
|
34
68
|
import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
|
|
35
|
-
import type { ProposedEdit } from "../sandbox/protocol.ts";
|
|
36
69
|
import { formatError } from "../util/errors.ts";
|
|
37
70
|
|
|
38
71
|
|
|
@@ -49,6 +82,56 @@ export interface EngineDeps extends InteractiveDeps {
|
|
|
49
82
|
readonly onUsage?: (usage: Usage, role: "root" | "sub") => void;
|
|
50
83
|
/** Run-state persistence handle. undefined ⇒ persistence off. */
|
|
51
84
|
readonly runState?: { readonly cwd: string; readonly dir: string; readonly snapshot: boolean };
|
|
85
|
+
/** Test-only: override model completion (scripted multi-turn responses). */
|
|
86
|
+
readonly complete?: import("./iteration.ts").CompleteFn;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Optional payload for a fresh-session history reset at a phase boundary. */
|
|
90
|
+
export interface PhaseHistoryOptions {
|
|
91
|
+
readonly goal?: GoalCapture;
|
|
92
|
+
readonly validation?: ValidationGateData;
|
|
93
|
+
/** Engine notice folded into the first user message (no console I/O). */
|
|
94
|
+
readonly notice?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Fresh-session policy: at each phase boundary the conversation is replaced;
|
|
99
|
+
* artifacts (paths) are the only channel. REPL variables persist — the transition
|
|
100
|
+
* message tells the model context survives in the sandbox, not in chat.
|
|
101
|
+
*/
|
|
102
|
+
export function resetHistoryForPhase(
|
|
103
|
+
system: string,
|
|
104
|
+
state: PhaseState,
|
|
105
|
+
options: PhaseHistoryOptions = {},
|
|
106
|
+
): ChatMsg[] {
|
|
107
|
+
const { goal, validation, notice } = options;
|
|
108
|
+
const parts: string[] = [
|
|
109
|
+
`You are entering the '${state.current}' phase.`,
|
|
110
|
+
];
|
|
111
|
+
if (notice) parts.push(notice);
|
|
112
|
+
if (goal) {
|
|
113
|
+
parts.push(`The user's verbatim brief: read ${goal.goalPath} from the REPL (open()).`);
|
|
114
|
+
parts.push(`Pre-run dirty baseline (exclude from delta judgment): ${goal.baselinePath}`);
|
|
115
|
+
}
|
|
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
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (validation) {
|
|
125
|
+
parts.push(
|
|
126
|
+
`Previous validation found ${validation.blockersCount} blocker(s) — read the validation artifact and address every blocker in the revised plan.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
parts.push(phaseGuidance(state.current));
|
|
130
|
+
parts.push("Your REPL variables persist; the chat history was reset to keep your window small.");
|
|
131
|
+
return [
|
|
132
|
+
{ role: "system", content: system },
|
|
133
|
+
{ role: "user", content: parts.join("\n") },
|
|
134
|
+
];
|
|
52
135
|
}
|
|
53
136
|
|
|
54
137
|
/** Build a `runRlm` bound to the given deps. The returned function is reused for recursion. */
|
|
@@ -57,6 +140,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
57
140
|
const run: RunRlm = async (input: RlmInput): Promise<RlmResult> => {
|
|
58
141
|
const nowIso = (): string => new Date().toISOString(); // local helper — 4 call sites below
|
|
59
142
|
const persist = input.depth === 0 && deps.runState !== undefined;
|
|
143
|
+
const runCwd = deps.runState?.cwd ?? process.cwd();
|
|
60
144
|
// Compute runId early for run-state correlation on resume.
|
|
61
145
|
const runId = persist
|
|
62
146
|
? (input.resume ? input.resume.header.runId : generateRunId())
|
|
@@ -78,7 +162,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
78
162
|
else emitter.emitStatus("error");
|
|
79
163
|
return {
|
|
80
164
|
answer: formatError(`unknown model override '${input.modelOverride}'`),
|
|
81
|
-
edits: [],
|
|
82
165
|
iterations: 0,
|
|
83
166
|
costUsd: 0,
|
|
84
167
|
inputTokens: 0,
|
|
@@ -137,8 +220,18 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
137
220
|
let lastAnswer = "";
|
|
138
221
|
let compactions = 0;
|
|
139
222
|
let completedTurns = 0;
|
|
140
|
-
let editsAcc: ProposedEdit[] = [];
|
|
141
223
|
let phaseState: PhaseState | undefined;
|
|
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[] = [];
|
|
231
|
+
/** Serviced ask_user_question rounds in the current phase (session-only; reset on transition). */
|
|
232
|
+
let askRoundsThisPhase = 0;
|
|
233
|
+
let pendingHistoryReset: ChatMsg[] | undefined;
|
|
234
|
+
let goal: GoalCapture | undefined;
|
|
142
235
|
let nodeStatus: "done" | "error" = "done";
|
|
143
236
|
let persistOn = persist;
|
|
144
237
|
if (persist && deps.runState && !input.resume && runId) {
|
|
@@ -167,26 +260,163 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
167
260
|
});
|
|
168
261
|
};
|
|
169
262
|
|
|
263
|
+
const persistPhaseRow = async (
|
|
264
|
+
state: PhaseState,
|
|
265
|
+
artifactPath: string | undefined,
|
|
266
|
+
artifactPhase: Phase | undefined,
|
|
267
|
+
gateData: StageGateData | undefined,
|
|
268
|
+
supersededPath?: string,
|
|
269
|
+
): Promise<void> => {
|
|
270
|
+
if (!persistOn || !runId || !deps.runState) return;
|
|
271
|
+
const row: PhaseRow = {
|
|
272
|
+
kind: "phase",
|
|
273
|
+
turn: completedTurns + 1,
|
|
274
|
+
ts: nowIso(),
|
|
275
|
+
phase: state.current,
|
|
276
|
+
summary: state.summary,
|
|
277
|
+
artifactPath,
|
|
278
|
+
artifactPhase,
|
|
279
|
+
blockersCount: gateData?.kind === "validation" ? gateData.validation.blockersCount : undefined,
|
|
280
|
+
backwardJumps: state.backwardJumps,
|
|
281
|
+
supersededPath,
|
|
282
|
+
};
|
|
283
|
+
const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, row);
|
|
284
|
+
if (!ok) persistOn = false;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/** Clear lastSaved so a re-entered stage cannot re-gate with a stale artifact. */
|
|
288
|
+
const clearLastSaved = (...phases: readonly Phase[]): void => {
|
|
289
|
+
const next: Partial<Record<Phase, SavedArtifact>> = { ...lastSaved };
|
|
290
|
+
for (const p of phases) delete next[p];
|
|
291
|
+
lastSaved = next;
|
|
292
|
+
};
|
|
293
|
+
|
|
170
294
|
try {
|
|
171
|
-
const
|
|
295
|
+
const pipelineOn = input.depth === 0 && deps.config.pipeline;
|
|
296
|
+
const meta = {
|
|
297
|
+
contextType: contextTypeLabel(input.context),
|
|
298
|
+
contextChars: contextLength(input.context),
|
|
299
|
+
contextStats: contextSizeStats(input.context),
|
|
300
|
+
rootPrompt: input.rootPrompt || undefined,
|
|
301
|
+
};
|
|
302
|
+
const system = buildRlmSystemPrompt(meta, {
|
|
303
|
+
orchestrator: deps.config.orchestrator,
|
|
304
|
+
recursion: input.depth + 1 < deps.config.maxDepth,
|
|
305
|
+
askUserQuestion: deps.config.askUserQuestion && input.depth === 0,
|
|
306
|
+
todo: deps.config.todo,
|
|
307
|
+
pipeline: deps.config.pipeline && input.depth === 0,
|
|
308
|
+
maxPromptChars: deps.config.maxPromptChars,
|
|
309
|
+
libraryLoader: deps.config.libraryLoader,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const phaseHandlers = pipelineOn
|
|
172
313
|
? {
|
|
173
|
-
|
|
174
|
-
const
|
|
314
|
+
saveArtifact: async (kind: string, content: string): Promise<string> => {
|
|
315
|
+
const stage = stageForArtifactKind(kind);
|
|
316
|
+
if (stage === undefined) {
|
|
317
|
+
return formatError(`unknown artifact kind '${kind}' (valid: clarification, research, plan, validation)`);
|
|
318
|
+
}
|
|
319
|
+
const current = phaseState?.current ?? PHASES[0];
|
|
320
|
+
if (stage.phase !== current) {
|
|
321
|
+
return formatError(
|
|
322
|
+
`artifact kind '${kind}' belongs to phase '${stage.phase}', but the pipeline is in '${current}'`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const saved = saveArtifact(runCwd, stage.artifactDir, kind, content);
|
|
326
|
+
if (!saved.ok) return formatError(saved.error);
|
|
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)}`;
|
|
347
|
+
},
|
|
348
|
+
advancePhase: async (phase: string, summary: string | undefined): Promise<string> => {
|
|
349
|
+
const current = phaseState?.current ?? PHASES[0];
|
|
350
|
+
const outcome = validatePhaseTransition(current, phase);
|
|
175
351
|
if (!outcome.ok) return formatError(outcome.error);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
352
|
+
|
|
353
|
+
// Clarify interview gate: engine counts serviced ask_user_question rounds
|
|
354
|
+
// (un-gameable — the model cannot advance without having actually asked).
|
|
355
|
+
if (current === "clarify" && askRoundsThisPhase === 0) {
|
|
356
|
+
return formatError(
|
|
357
|
+
"clarify requires at least one ask_user_question round — interview the user before advancing",
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// GATE: measure the CURRENT stage's latest save only (never fall back to
|
|
362
|
+
// phaseState.artifacts — those are completed-channel paths and may be stale
|
|
363
|
+
// across a corrective loop). Prefer the memo filled by save_artifact.
|
|
364
|
+
const stage = STAGES[current];
|
|
365
|
+
const savedEntry = lastSaved[current];
|
|
366
|
+
const artifactPath = savedEntry?.path;
|
|
367
|
+
if (stage.artifactDir !== "" && artifactPath === undefined) {
|
|
368
|
+
return formatError(
|
|
369
|
+
`phase '${current}' has no saved artifact — call save_artifact("${stage.artifactKind}", content) first`,
|
|
370
|
+
);
|
|
182
371
|
}
|
|
183
|
-
|
|
184
|
-
|
|
372
|
+
let gateData: StageGateData | undefined;
|
|
373
|
+
if (stage.artifactDir !== "" && artifactPath !== undefined) {
|
|
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
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Transition accepted: persist row, schedule root history reset (fresh session).
|
|
390
|
+
const prevArtifacts = phaseState?.artifacts ?? {};
|
|
391
|
+
const nextArtifacts: Partial<Record<Phase, ArtifactRef>> = { ...prevArtifacts };
|
|
392
|
+
if (artifactPath !== undefined) {
|
|
393
|
+
nextArtifacts[current] = Object.freeze({ path: artifactPath, status: "active" });
|
|
394
|
+
}
|
|
395
|
+
phaseState = {
|
|
396
|
+
current: outcome.phase,
|
|
397
|
+
advancedAt: completedTurns,
|
|
398
|
+
summary,
|
|
399
|
+
artifacts: nextArtifacts,
|
|
400
|
+
backwardJumps: phaseState?.backwardJumps ?? 0,
|
|
401
|
+
};
|
|
402
|
+
await persistPhaseRow(phaseState, artifactPath, artifactPath !== undefined ? current : undefined, gateData);
|
|
403
|
+
clearLastSaved(current);
|
|
404
|
+
askRoundsThisPhase = 0;
|
|
405
|
+
pendingHistoryReset = resetHistoryForPhase(system, phaseState, { goal });
|
|
406
|
+
return `ok — phase advanced to '${outcome.phase}' (was '${current}'${summary ? `, summary: ${summary.slice(0, 80)}` : ""})`;
|
|
185
407
|
},
|
|
186
408
|
}
|
|
187
409
|
: {};
|
|
410
|
+
const baseAsk = deps.config.askUserQuestion ? deps.onAskUserQuestion : undefined;
|
|
188
411
|
const interactiveHandlers = buildInteractiveHandlers({
|
|
189
|
-
onAskUserQuestion:
|
|
412
|
+
onAskUserQuestion: baseAsk
|
|
413
|
+
? async (questions) => {
|
|
414
|
+
const answers = await baseAsk(questions);
|
|
415
|
+
// Count only successfully serviced root-depth rounds (handler already rejects depth>0).
|
|
416
|
+
askRoundsThisPhase++;
|
|
417
|
+
return answers;
|
|
418
|
+
}
|
|
419
|
+
: undefined,
|
|
190
420
|
onTodo: deps.config.todo ? deps.onTodo : undefined,
|
|
191
421
|
onTodoRow: async (action, params, todoResult) => {
|
|
192
422
|
if (!persistOn || !runId || !deps.runState) return;
|
|
@@ -200,6 +430,39 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
200
430
|
parentId: selfReportId,
|
|
201
431
|
});
|
|
202
432
|
|
|
433
|
+
const restoredSlots = input.resume && deps.runState && runId
|
|
434
|
+
? await readLibrarySidecars(deps.runState.cwd, deps.runState.dir, runId)
|
|
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
|
+
}
|
|
448
|
+
const libraryHandlers = deps.config.libraryLoader
|
|
449
|
+
? buildLibraryHandler({
|
|
450
|
+
cwd: runCwd,
|
|
451
|
+
emitter,
|
|
452
|
+
parentId: selfReportId,
|
|
453
|
+
signal: deps.signal,
|
|
454
|
+
startIndex: 1 + restoredSlots.reduce((m, s) => Math.max(m, s.index), 0),
|
|
455
|
+
loadedPrefixes: restoredPrefixes,
|
|
456
|
+
onLoaded: async (index, payload) => {
|
|
457
|
+
if (!persistOn || !runId || !deps.runState) return;
|
|
458
|
+
await writeContextSidecar(
|
|
459
|
+
deps.runState.cwd, deps.runState.dir, runId,
|
|
460
|
+
payload, typeof payload !== "string", index,
|
|
461
|
+
);
|
|
462
|
+
},
|
|
463
|
+
}).handlers
|
|
464
|
+
: {};
|
|
465
|
+
|
|
203
466
|
sandbox = await PythonSandbox.spawn({
|
|
204
467
|
depth: input.depth,
|
|
205
468
|
execTimeoutS: deps.config.execTimeoutS,
|
|
@@ -208,42 +471,76 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
208
471
|
signal: deps.signal,
|
|
209
472
|
initTimeoutMs: deps.config.sandboxInitTimeoutMs,
|
|
210
473
|
maxPromptChars: deps.config.maxPromptChars,
|
|
211
|
-
|
|
474
|
+
// Pipeline at depth 0 is read-only: guard open() write modes in the worker.
|
|
475
|
+
readOnly: pipelineOn,
|
|
476
|
+
handlers: { ...llm, ...rlm, ...phaseHandlers, ...interactiveHandlers, ...libraryHandlers },
|
|
212
477
|
});
|
|
213
478
|
|
|
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
479
|
let history: ChatMsg[] = input.resume ? input.resume.history : [{ role: "system", content: system }];
|
|
229
480
|
let pendingReplOutputs: string | undefined = input.resume?.pendingReplOutputs;
|
|
230
481
|
const startTurn = input.resume?.completedTurns ?? 0;
|
|
231
482
|
if (input.resume) {
|
|
232
483
|
limits.addRaw(input.resume.usageSeed.costUsd, input.resume.usageSeed.inputTokens, input.resume.usageSeed.outputTokens);
|
|
233
484
|
best = input.resume.best;
|
|
234
|
-
editsAcc = [];
|
|
235
485
|
compactions = input.resume.compactions;
|
|
236
486
|
completedTurns = input.resume.completedTurns;
|
|
237
487
|
if (input.resume.phase) {
|
|
238
488
|
const resumePhase = input.resume.phase;
|
|
239
|
-
|
|
489
|
+
const artifacts: Partial<Record<Phase, ArtifactRef>> = {};
|
|
490
|
+
if (resumePhase.artifacts) {
|
|
491
|
+
for (const [k, v] of Object.entries(resumePhase.artifacts)) {
|
|
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
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
phaseState = {
|
|
501
|
+
current: reconcilePhase(resumePhase.current),
|
|
502
|
+
advancedAt: resumePhase.advancedAt,
|
|
503
|
+
summary: resumePhase.summary,
|
|
504
|
+
artifacts,
|
|
505
|
+
backwardJumps: resumePhase.backwardJumps ?? 0,
|
|
506
|
+
};
|
|
507
|
+
// lastSaved is session-only: never rehydrate from trail (would re-gate stale
|
|
508
|
+
// plan/validation after loop-back / mid-stage resume without a fresh save).
|
|
509
|
+
lastSaved = {};
|
|
510
|
+
// askRoundsThisPhase is session-only (like lastSaved): a resume mid-clarify
|
|
511
|
+
// restarts the interview count so the model must ask again in this process.
|
|
512
|
+
askRoundsThisPhase = 0;
|
|
513
|
+
}
|
|
514
|
+
} else if (pipelineOn) {
|
|
515
|
+
// Goal capture (script, no LLM) + seed phase state + fresh history.
|
|
516
|
+
const captured = captureGoal(runCwd, input.rootPrompt);
|
|
517
|
+
let goalNotice: string | undefined;
|
|
518
|
+
if (captured.ok) {
|
|
519
|
+
goal = captured.value;
|
|
520
|
+
} else {
|
|
521
|
+
// Fail-soft: fold into the first reset message (never console — corrupts TUI).
|
|
522
|
+
goalNotice = `Note: goal artifact could not be written (${captured.error}); the brief remains only in the system prompt.`;
|
|
240
523
|
}
|
|
524
|
+
// Clarify only when interviews are enabled AND the host wired a callback.
|
|
525
|
+
// Config alone is not enough: without onAskUserQuestion every ask throws and
|
|
526
|
+
// the run would burn maxIterations stuck at clarify (askRounds stays 0).
|
|
527
|
+
const startPhase =
|
|
528
|
+
deps.config.askUserQuestion && deps.onAskUserQuestion !== undefined
|
|
529
|
+
? "clarify"
|
|
530
|
+
: "research";
|
|
531
|
+
phaseState = initialPhaseState(0, startPhase);
|
|
532
|
+
history = resetHistoryForPhase(system, phaseState, { goal, notice: goalNotice });
|
|
241
533
|
}
|
|
242
534
|
|
|
243
535
|
// Context: serialize ContextBundle to sandbox-ready JSON array, pass raw strings through.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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;
|
|
541
|
+
for (const slot of restoredSlots) {
|
|
542
|
+
contextValue = mergeLibraryIntoContext(contextValue, slot.payload);
|
|
543
|
+
}
|
|
247
544
|
await sandbox.loadContext(contextValue);
|
|
248
545
|
if (input.resume?.snapshotTurn !== undefined && deps.runState && runId && sessionNonce) // R-C1: restore only for same-session (sessionNonce present)
|
|
249
546
|
await sandbox.restore(snapshotPath(deps.runState.cwd, deps.runState.dir, runId, input.resume.snapshotTurn), sessionNonce);
|
|
@@ -252,6 +549,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
252
549
|
if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
|
|
253
550
|
else emitter.emitTurn(i + 1, deps.config.maxIterations);
|
|
254
551
|
|
|
552
|
+
// Apply deferred history reset from a prior advance_phase (fresh session policy).
|
|
553
|
+
if (pendingHistoryReset !== undefined) {
|
|
554
|
+
history = pendingHistoryReset;
|
|
555
|
+
pendingHistoryReset = undefined;
|
|
556
|
+
pendingReplOutputs = undefined;
|
|
557
|
+
}
|
|
558
|
+
|
|
255
559
|
if (deps.config.compaction) {
|
|
256
560
|
const compactionDeps = {
|
|
257
561
|
// Summarisation is done by the cheap worker model; the threshold stays on the
|
|
@@ -286,7 +590,12 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
286
590
|
|
|
287
591
|
const gateMsg = deps.config.pipeline ? phaseGatePrompt(phaseState, completedTurns) : undefined;
|
|
288
592
|
const gateUserMsg = gateMsg ? `[${new Date().toISOString()}] ${gateMsg}` : undefined;
|
|
289
|
-
|
|
593
|
+
// Phase guidance lives only in resetHistoryForPhase (fresh session) — do not re-inject
|
|
594
|
+
// into every turn prompt (avoids duplication on turn 1 and dead post-transition flags).
|
|
595
|
+
appendUserMessage(
|
|
596
|
+
history,
|
|
597
|
+
buildTurnPrompt(i, deps.config.maxIterations, gateUserMsg),
|
|
598
|
+
);
|
|
290
599
|
|
|
291
600
|
// rootSampling fields win; smartReasoning is the default reasoning when not overridden.
|
|
292
601
|
const rootSampling: Sampling = {
|
|
@@ -298,6 +607,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
298
607
|
registry: deps.registry,
|
|
299
608
|
sampling: rootSampling,
|
|
300
609
|
signal: deps.signal,
|
|
610
|
+
complete: deps.complete,
|
|
301
611
|
});
|
|
302
612
|
const allBlocks = turn.blocks.length > 0
|
|
303
613
|
? turn.blocks.map((b) => previewText(b, 400)).join("\n")
|
|
@@ -313,20 +623,102 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
313
623
|
if (answerContent) best = answerContent;
|
|
314
624
|
else if (!best && turn.response.trim()) best = turn.response;
|
|
315
625
|
completedTurns = i + 1;
|
|
316
|
-
const proposedEdits = collectEdits(turn.results);
|
|
317
|
-
if (proposedEdits.length > 0) editsAcc = proposedEdits;
|
|
318
626
|
const final = finalAnswerOf(turn.results);
|
|
319
627
|
if (final != null) {
|
|
320
|
-
|
|
628
|
+
// Validate-phase finalize: measure THIS turn's validation save only (lastSaved),
|
|
629
|
+
// never fall back to phaseState.artifacts (stale after a prior loop).
|
|
630
|
+
if (pipelineOn && phaseState?.current === "validate") {
|
|
631
|
+
const vPath = lastSaved.validate?.path;
|
|
632
|
+
if (vPath === undefined) {
|
|
633
|
+
// Reject finalize — push error into next turn.
|
|
634
|
+
history.push({ role: "assistant", content: turn.response });
|
|
635
|
+
pendingReplOutputs = formatError(
|
|
636
|
+
"finalize rejected — save the validation artifact first via save_artifact(\"validation\", content) with status: ready, blockers_count, and verdict",
|
|
637
|
+
);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
const content = readArtifact(runCwd, vPath);
|
|
641
|
+
if (!content.ok) {
|
|
642
|
+
history.push({ role: "assistant", content: turn.response });
|
|
643
|
+
pendingReplOutputs = formatError(content.error);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const gate = STAGES.validate.gate(content.value, vPath, runCwd);
|
|
647
|
+
if (!gate.ok) {
|
|
648
|
+
history.push({ role: "assistant", content: turn.response });
|
|
649
|
+
pendingReplOutputs = formatError(gate.error);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (gate.value.kind !== "validation") {
|
|
653
|
+
history.push({ role: "assistant", content: turn.response });
|
|
654
|
+
pendingReplOutputs = formatError("internal: validate gate did not return validation data");
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const validation = gate.value.validation;
|
|
658
|
+
const route = routeAfterValidate(
|
|
659
|
+
validation,
|
|
660
|
+
phaseState.backwardJumps,
|
|
661
|
+
deps.config.maxBackwardJumps,
|
|
662
|
+
);
|
|
663
|
+
if (route.kind === "loop-back") {
|
|
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" }),
|
|
670
|
+
};
|
|
671
|
+
if (prior !== undefined) {
|
|
672
|
+
nextArtifacts.blueprint = Object.freeze({
|
|
673
|
+
path: prior.path,
|
|
674
|
+
status: "superseded",
|
|
675
|
+
supersededBy: vPath,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
phaseState = {
|
|
679
|
+
current: "blueprint",
|
|
680
|
+
advancedAt: completedTurns,
|
|
681
|
+
summary: `loop-back: ${validation.blockersCount} blocker(s)`,
|
|
682
|
+
artifacts: nextArtifacts,
|
|
683
|
+
backwardJumps: phaseState.backwardJumps + 1,
|
|
684
|
+
};
|
|
685
|
+
await persistPhaseRow(phaseState, vPath, "validate", gate.value, prior?.path);
|
|
686
|
+
clearLastSaved("blueprint", "validate");
|
|
687
|
+
askRoundsThisPhase = 0;
|
|
688
|
+
history = resetHistoryForPhase(system, phaseState, { goal, validation });
|
|
689
|
+
pendingReplOutputs = undefined;
|
|
690
|
+
pendingHistoryReset = undefined;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (route.kind === "halt") {
|
|
694
|
+
const report = `${route.reason}\n\n${final}`;
|
|
695
|
+
const halted = result(report, i + 1, limits);
|
|
696
|
+
await recordTerminal("completed", halted);
|
|
697
|
+
lastAnswer = halted.answer;
|
|
698
|
+
return halted;
|
|
699
|
+
}
|
|
700
|
+
// route.kind === "done" — accept final answer
|
|
701
|
+
}
|
|
702
|
+
const done = result(final, i + 1, limits);
|
|
321
703
|
await recordTerminal("completed", done);
|
|
322
704
|
lastAnswer = done.answer;
|
|
323
705
|
return done;
|
|
324
706
|
}
|
|
325
707
|
|
|
326
708
|
limits.observe(turnHadError(turn.results));
|
|
327
|
-
|
|
709
|
+
// Always capture this turn's REPL outputs for the JSONL trail (fidelity).
|
|
328
710
|
const turnReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
|
|
329
|
-
|
|
711
|
+
// If advance_phase scheduled a history reset, apply it now (do not pollute fresh history).
|
|
712
|
+
if (pendingHistoryReset !== undefined) {
|
|
713
|
+
history = pendingHistoryReset;
|
|
714
|
+
pendingHistoryReset = undefined;
|
|
715
|
+
// Fanout/advance result is already embedded in the reset user message — do not
|
|
716
|
+
// also append raw REPL stdout as a next-turn user message.
|
|
717
|
+
pendingReplOutputs = undefined;
|
|
718
|
+
} else {
|
|
719
|
+
history.push({ role: "assistant", content: turn.response });
|
|
720
|
+
pendingReplOutputs = turnReplOutputs;
|
|
721
|
+
}
|
|
330
722
|
|
|
331
723
|
if (persistOn && runId && deps.runState) {
|
|
332
724
|
const pklPath = snapshotPath(deps.runState.cwd, deps.runState.dir, runId, i + 1);
|
|
@@ -335,9 +727,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
335
727
|
: false;
|
|
336
728
|
const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
|
|
337
729
|
kind: "turn", turn: i + 1, ts: nowIso(),
|
|
338
|
-
response: turn.response,
|
|
730
|
+
response: turn.response,
|
|
731
|
+
// Trail keeps the real REPL output even when history was reset (issue #9).
|
|
732
|
+
replOutputs: turnReplOutputs || undefined,
|
|
339
733
|
answerContent: answerContent || undefined,
|
|
340
|
-
edits: proposedEdits.length > 0 ? proposedEdits : undefined,
|
|
341
734
|
error: turnHadError(turn.results),
|
|
342
735
|
usage: { costUsd: turn.usage.cost.total, inputTokens: turn.usage.input, outputTokens: turn.usage.output }, // B2: Usage has .input/.output, not .inputTokens/.outputTokens
|
|
343
736
|
cumulativeDurationMs: limits.usage().durationMs, // B3: required by TurnRow, seeds LimitGuard clock on resume (CA)
|
|
@@ -348,21 +741,21 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
348
741
|
}
|
|
349
742
|
}
|
|
350
743
|
if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
|
|
351
|
-
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);
|
|
352
745
|
await recordTerminal("finalized", finalized);
|
|
353
746
|
lastAnswer = finalized.answer;
|
|
354
747
|
return finalized;
|
|
355
748
|
} catch (err) {
|
|
356
749
|
// Abort is a user action — resolve with the best partial, not an error.
|
|
357
750
|
if (deps.signal?.aborted) {
|
|
358
|
-
const aborted = result(best.trim() || "(aborted)", completedTurns, limits
|
|
751
|
+
const aborted = result(best.trim() || "(aborted)", completedTurns, limits);
|
|
359
752
|
await recordTerminal("aborted", aborted);
|
|
360
753
|
lastAnswer = aborted.answer;
|
|
361
754
|
return aborted;
|
|
362
755
|
}
|
|
363
756
|
if (err instanceof LimitError) {
|
|
364
757
|
nodeStatus = "error";
|
|
365
|
-
const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits
|
|
758
|
+
const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits);
|
|
366
759
|
await recordTerminal("stopped", stopped);
|
|
367
760
|
lastAnswer = stopped.answer;
|
|
368
761
|
return stopped;
|
|
@@ -379,7 +772,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
379
772
|
});
|
|
380
773
|
} else {
|
|
381
774
|
if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
|
|
382
|
-
emitter.emitEdits(editsAcc.length > 0 ? editsAcc : []);
|
|
383
775
|
emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
|
|
384
776
|
}
|
|
385
777
|
await sandbox?.dispose();
|
|
@@ -388,16 +780,17 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
388
780
|
return run;
|
|
389
781
|
}
|
|
390
782
|
|
|
391
|
-
function result(answer: string, iterations: number, limits: LimitGuard
|
|
783
|
+
function result(answer: string, iterations: number, limits: LimitGuard): RlmResult {
|
|
392
784
|
const u = limits.usage();
|
|
393
|
-
return { answer,
|
|
785
|
+
return { answer, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
|
|
394
786
|
}
|
|
395
787
|
|
|
396
788
|
/** Out of turns: ask the model for its best final answer (plain text). */
|
|
397
789
|
async function finalize(history: ChatMsg[], model: Model<Api>, deps: EngineDeps, limits: LimitGuard): Promise<string> {
|
|
398
790
|
const finalHistory = [...history];
|
|
399
791
|
appendUserMessage(finalHistory, FINALIZE_PROMPT);
|
|
400
|
-
const
|
|
792
|
+
const complete = deps.complete ?? modelComplete;
|
|
793
|
+
const { text, usage } = await complete(finalHistory, {
|
|
401
794
|
model,
|
|
402
795
|
registry: deps.registry,
|
|
403
796
|
reasoning: deps.config.smartReasoning,
|