@hicaru/pi-rlm 0.1.9 → 0.2.1
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/package.json +1 -1
- package/src/bridge/fallback-todo.ts +12 -1
- package/src/bridge/subcall-handlers.ts +336 -0
- package/src/commands/rlm-config.ts +8 -8
- package/src/commands/rlm.ts +48 -12
- package/src/config/defaults.ts +4 -1
- package/src/config/settings.ts +33 -3
- package/src/context/repomix-context.ts +5 -10
- package/src/core/answer.ts +4 -3
- package/src/core/artifacts.ts +4 -3
- package/src/core/engine.ts +101 -267
- package/src/core/gates.ts +3 -3
- package/src/core/limits.ts +19 -1
- package/src/core/pipeline-handlers.ts +319 -0
- package/src/core/pipeline.ts +2 -2
- package/src/core/types.ts +25 -27
- package/src/index.ts +63 -17
- package/src/mode/rlm-mode.ts +8 -11
- package/src/prompts/system.ts +164 -52
- package/src/prompts/user.ts +1 -5
- package/src/sandbox/protocol.ts +6 -7
- package/src/sandbox/sandbox-manager.ts +25 -11
- package/src/sandbox/sandbox.ts +93 -22
- package/src/sandbox/worker.py +798 -66
- package/src/state/paths.ts +1 -1
- package/src/state/reads.ts +12 -4
- package/src/state/resume.ts +5 -11
- package/src/text/parsing.ts +0 -6
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +2 -0
- package/src/tool/repl-tool.ts +223 -318
- package/src/tool/rlm-details.ts +0 -10
- package/src/tool/rlm-events.ts +10 -2
- package/src/tool/rlm-tool.ts +18 -31
- package/src/tool/subcall-render.ts +75 -11
- package/src/tool/subcall-store.ts +57 -1
- package/src/ui/config-panel.ts +41 -21
- package/src/ui/intro.ts +2 -1
- package/src/ui/status.ts +8 -5
- package/src/ui/theme-adapter.ts +36 -0
- package/src/ui/theme.ts +0 -25
- package/src/util/concurrency.ts +87 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/llm-query.ts +0 -133
- package/src/bridge/rlm-query.ts +0 -122
- package/src/mode/input-router.ts +0 -23
package/src/core/answer.ts
CHANGED
|
@@ -37,13 +37,14 @@ export function formatReplOutputs(results: readonly ReplResult[], skippedBlocks
|
|
|
37
37
|
return "No ```repl``` block found in your response. Write one to interact with the REPL.";
|
|
38
38
|
}
|
|
39
39
|
const multi = results.length > 1;
|
|
40
|
-
const parts
|
|
40
|
+
const parts = new Array<string>(results.length);
|
|
41
41
|
let hadElision = false;
|
|
42
|
-
for (
|
|
42
|
+
for (let i = 0; i < results.length; i++) {
|
|
43
|
+
const r = results[i];
|
|
43
44
|
const head = multi ? `[block ${i + 1}]\n` : "";
|
|
44
45
|
const { text, elided } = formatStdout(r);
|
|
45
46
|
hadElision ||= elided;
|
|
46
|
-
parts
|
|
47
|
+
parts[i] = `${head}${text}${formatStderr(r)}`;
|
|
47
48
|
}
|
|
48
49
|
const body = parts.join("\n\n");
|
|
49
50
|
const skipNote = skippedBlocks > 0
|
package/src/core/artifacts.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { execFileSync } from "node:child_process";
|
|
|
6
6
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import type { Result } from "../util/errors.ts";
|
|
9
|
+
import { errorMessage } from "../util/errors.ts";
|
|
9
10
|
|
|
10
11
|
export const ARTIFACTS_DIR = ".rlm/artifacts";
|
|
11
12
|
|
|
@@ -59,7 +60,7 @@ export function captureGoal(cwd: string, brief: string): GoalCaptureResult {
|
|
|
59
60
|
writeFileSync(join(cwd, baselinePath), JSON.stringify({ paths }, null, 2), "utf-8");
|
|
60
61
|
return { ok: true, value: { goalPath, baselinePath } };
|
|
61
62
|
} catch (err) {
|
|
62
|
-
const message =
|
|
63
|
+
const message = errorMessage(err);
|
|
63
64
|
return { ok: false, error: message };
|
|
64
65
|
}
|
|
65
66
|
}
|
|
@@ -72,7 +73,7 @@ export function saveArtifact(cwd: string, dir: string, slug: string, content: st
|
|
|
72
73
|
writeFileSync(join(cwd, rel), content, "utf-8");
|
|
73
74
|
return { ok: true, path: rel };
|
|
74
75
|
} catch (err) {
|
|
75
|
-
const message =
|
|
76
|
+
const message = errorMessage(err);
|
|
76
77
|
return { ok: false, error: message };
|
|
77
78
|
}
|
|
78
79
|
}
|
|
@@ -82,7 +83,7 @@ export function readArtifact(cwd: string, relPath: string): Result<string, strin
|
|
|
82
83
|
try {
|
|
83
84
|
return { ok: true, value: readFileSync(join(cwd, relPath), "utf-8") };
|
|
84
85
|
} catch (err) {
|
|
85
|
-
const message =
|
|
86
|
+
const message = errorMessage(err);
|
|
86
87
|
return { ok: false, error: `could not read artifact ${relPath}: ${message}` };
|
|
87
88
|
}
|
|
88
89
|
}
|
package/src/core/engine.ts
CHANGED
|
@@ -17,9 +17,11 @@ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
|
17
17
|
import { buildInteractiveHandlers } from "../bridge/interactive.ts";
|
|
18
18
|
import { buildLibraryHandler } from "../bridge/library.ts";
|
|
19
19
|
import { mergeLibraryIntoContext } from "../context/library-context.ts";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
createSubcallHandlers,
|
|
22
|
+
type Invocation,
|
|
23
|
+
} from "../bridge/subcall-handlers.ts";
|
|
21
24
|
import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
22
|
-
import { createRlmHandlers } from "../bridge/rlm-query.ts";
|
|
23
25
|
import { resolveModelId } from "../config/settings.ts";
|
|
24
26
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
25
27
|
import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
|
|
@@ -27,24 +29,14 @@ import { phaseGuidance } from "../prompts/phases.ts";
|
|
|
27
29
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
28
30
|
import { PythonSandbox } from "../sandbox/sandbox.ts";
|
|
29
31
|
import {
|
|
30
|
-
advancePhase as validatePhaseTransition,
|
|
31
|
-
initialPhaseState,
|
|
32
|
-
isPhase,
|
|
33
32
|
phaseGatePrompt,
|
|
34
|
-
PHASES,
|
|
35
|
-
reconcilePhase,
|
|
36
|
-
routeAfterValidate,
|
|
37
|
-
stageForArtifactKind,
|
|
38
|
-
STAGES,
|
|
39
|
-
type ArtifactRef,
|
|
40
33
|
type Phase,
|
|
41
34
|
type PhaseState,
|
|
42
|
-
type SavedArtifact,
|
|
43
35
|
type StageGateData,
|
|
44
36
|
} from "./pipeline.ts";
|
|
45
|
-
import {
|
|
37
|
+
import { PipelineController } from "./pipeline-handlers.ts";
|
|
46
38
|
import type { ValidationGateData } from "./gates.ts";
|
|
47
|
-
import { captureGoal,
|
|
39
|
+
import { captureGoal, type GoalCapture } from "./artifacts.ts";
|
|
48
40
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
49
41
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
50
42
|
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
@@ -67,6 +59,14 @@ import { STATE_SCHEMA_VERSION } from "../state/rows.ts";
|
|
|
67
59
|
import type { PhaseRow, RunHeader } from "../state/rows.ts";
|
|
68
60
|
import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
|
|
69
61
|
import { formatError } from "../util/errors.ts";
|
|
62
|
+
import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Grace period for detached sub-calls to settle before the run disposes its sandbox.
|
|
66
|
+
* Past it the abort signal (or process exit) is what stops them; waiting longer would
|
|
67
|
+
* hold a finished run open on work whose result nobody can receive.
|
|
68
|
+
*/
|
|
69
|
+
const DETACHED_SETTLE_MS = 5_000;
|
|
70
70
|
|
|
71
71
|
|
|
72
72
|
export interface EngineDeps extends InteractiveDeps {
|
|
@@ -75,6 +75,8 @@ export interface EngineDeps extends InteractiveDeps {
|
|
|
75
75
|
readonly registry: ModelRegistry;
|
|
76
76
|
readonly config: RlmConfig;
|
|
77
77
|
readonly limits?: Limits;
|
|
78
|
+
/** Session-wide sub-call admission, shared with the repl() tool. Private one if omitted. */
|
|
79
|
+
readonly gates?: SubcallGates;
|
|
78
80
|
readonly signal?: AbortSignal;
|
|
79
81
|
/** Live RlmDetails reporting via onUpdate. Required — replaces SubcallObserver. */
|
|
80
82
|
readonly emitter: RlmEmitter;
|
|
@@ -181,57 +183,65 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
181
183
|
maxErrors: deps.limits?.maxErrors,
|
|
182
184
|
maxTokens: deps.limits?.maxTokens,
|
|
183
185
|
}, input.resume?.usageSeed.durationMs ?? 0);
|
|
184
|
-
const remainingBudget = (): { readonly budgetUsd?: number; readonly timeoutMs?: number } => ({
|
|
185
|
-
budgetUsd: limits.remainingBudgetUsd(),
|
|
186
|
-
timeoutMs: limits.remainingTimeoutMs(),
|
|
187
|
-
});
|
|
188
186
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
maxPromptChars: deps.config.maxPromptChars,
|
|
194
|
-
maxConcurrent: deps.config.maxConcurrentSubcalls,
|
|
195
|
-
sampling: deps.config.subSampling,
|
|
196
|
-
signal: deps.signal,
|
|
197
|
-
onUsage: (u) => {
|
|
198
|
-
limits.addUsage(u);
|
|
199
|
-
deps.onUsage?.(u, "sub");
|
|
200
|
-
},
|
|
187
|
+
// One Invocation for the whole run: this engine owns exactly one sandbox at one depth,
|
|
188
|
+
// and its emitter and LimitGuard outlive every sub-call it services — including
|
|
189
|
+
// detached ones, which is why the headless path needs no session registry.
|
|
190
|
+
const invocation: Invocation = {
|
|
201
191
|
emitter,
|
|
202
192
|
parentId: selfReportId,
|
|
203
193
|
depth: input.depth,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
194
|
+
limits: {
|
|
195
|
+
remainingBudgetUsd: () => limits.remainingBudgetUsd(),
|
|
196
|
+
remainingTimeoutMs: () => limits.remainingTimeoutMs(),
|
|
197
|
+
addUsage: (u) => {
|
|
198
|
+
limits.addUsage(u);
|
|
199
|
+
deps.onUsage?.(u, "sub");
|
|
200
|
+
},
|
|
201
|
+
addRaw: (costUsd, inputTokens, outputTokens) => {
|
|
202
|
+
limits.addRaw(costUsd, inputTokens, outputTokens);
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
// Detached work must not outlive the sandbox we dispose in `finally`: track it so the
|
|
207
|
+
// run can settle or abort it first (a child engine left running would keep spending).
|
|
208
|
+
let detachedInFlight = 0;
|
|
209
|
+
let detachedIdle: (() => void) | undefined;
|
|
210
|
+
const subcalls = createSubcallHandlers({
|
|
211
|
+
resolve: () => invocation,
|
|
212
|
+
gates: deps.gates ?? createSubcallGates(deps.config.maxConcurrentSubcalls),
|
|
213
|
+
registry: deps.registry,
|
|
214
|
+
getWorkerModel: () => deps.workerModel,
|
|
215
|
+
getModel: () => model,
|
|
216
|
+
getConfig: () => deps.config,
|
|
217
|
+
signal: deps.signal,
|
|
218
|
+
runChild: run,
|
|
219
|
+
trackDetached: async (task) => {
|
|
220
|
+
detachedInFlight += 1;
|
|
221
|
+
try {
|
|
222
|
+
return await task();
|
|
223
|
+
} finally {
|
|
224
|
+
detachedInFlight -= 1;
|
|
225
|
+
if (detachedInFlight === 0) detachedIdle?.();
|
|
226
|
+
}
|
|
216
227
|
},
|
|
217
228
|
});
|
|
229
|
+
/** Wait (bounded) for detached work before the sandbox goes away. */
|
|
230
|
+
const settleDetached = async (): Promise<void> => {
|
|
231
|
+
if (detachedInFlight === 0) return;
|
|
232
|
+
await new Promise<void>((resolve) => {
|
|
233
|
+
detachedIdle = resolve;
|
|
234
|
+
setTimeout(resolve, DETACHED_SETTLE_MS).unref?.();
|
|
235
|
+
});
|
|
236
|
+
detachedIdle = undefined;
|
|
237
|
+
};
|
|
218
238
|
let sandbox: PythonSandbox | undefined;
|
|
219
239
|
let best = "";
|
|
220
240
|
let lastAnswer = "";
|
|
221
241
|
let compactions = 0;
|
|
222
242
|
let completedTurns = 0;
|
|
223
|
-
|
|
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;
|
|
243
|
+
/** Owns all pipeline state (phase, per-phase latest save, ask rounds, pending reset). */
|
|
244
|
+
let pipeline: PipelineController | undefined;
|
|
235
245
|
let nodeStatus: "done" | "error" = "done";
|
|
236
246
|
let persistOn = persist;
|
|
237
247
|
if (persist && deps.runState && !input.resume && runId) {
|
|
@@ -284,13 +294,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
284
294
|
if (!ok) persistOn = false;
|
|
285
295
|
};
|
|
286
296
|
|
|
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
|
-
|
|
294
297
|
try {
|
|
295
298
|
const pipelineOn = input.depth === 0 && deps.config.pipeline;
|
|
296
299
|
const meta = {
|
|
@@ -309,111 +312,22 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
309
312
|
libraryLoader: deps.config.libraryLoader,
|
|
310
313
|
});
|
|
311
314
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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);
|
|
351
|
-
if (!outcome.ok) return formatError(outcome.error);
|
|
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
|
-
);
|
|
371
|
-
}
|
|
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)}` : ""})`;
|
|
407
|
-
},
|
|
408
|
-
}
|
|
409
|
-
: {};
|
|
315
|
+
pipeline = new PipelineController({
|
|
316
|
+
runCwd,
|
|
317
|
+
maxBackwardJumps: deps.config.maxBackwardJumps,
|
|
318
|
+
emitter,
|
|
319
|
+
completedTurns: () => completedTurns,
|
|
320
|
+
resetHistoryForPhase: (state, options) => resetHistoryForPhase(system, state, options),
|
|
321
|
+
persistPhaseRow,
|
|
322
|
+
});
|
|
323
|
+
const phaseHandlers = pipelineOn ? pipeline.handlers() : {};
|
|
410
324
|
const baseAsk = deps.config.askUserQuestion ? deps.onAskUserQuestion : undefined;
|
|
411
325
|
const interactiveHandlers = buildInteractiveHandlers({
|
|
412
326
|
onAskUserQuestion: baseAsk
|
|
413
327
|
? async (questions) => {
|
|
414
328
|
const answers = await baseAsk(questions);
|
|
415
329
|
// Count only successfully serviced root-depth rounds (handler already rejects depth>0).
|
|
416
|
-
|
|
330
|
+
pipeline?.noteAskRound();
|
|
417
331
|
return answers;
|
|
418
332
|
}
|
|
419
333
|
: undefined,
|
|
@@ -471,9 +385,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
471
385
|
signal: deps.signal,
|
|
472
386
|
initTimeoutMs: deps.config.sandboxInitTimeoutMs,
|
|
473
387
|
maxPromptChars: deps.config.maxPromptChars,
|
|
388
|
+
awaitTimeoutS: Math.round(deps.config.requestTimeoutMs / 1000),
|
|
474
389
|
// Pipeline at depth 0 is read-only: guard open() write modes in the worker.
|
|
475
390
|
readOnly: pipelineOn,
|
|
476
|
-
handlers: { ...
|
|
391
|
+
handlers: { ...subcalls, ...phaseHandlers, ...interactiveHandlers, ...libraryHandlers },
|
|
477
392
|
});
|
|
478
393
|
|
|
479
394
|
let history: ChatMsg[] = input.resume ? input.resume.history : [{ role: "system", content: system }];
|
|
@@ -484,43 +399,14 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
484
399
|
best = input.resume.best;
|
|
485
400
|
compactions = input.resume.compactions;
|
|
486
401
|
completedTurns = input.resume.completedTurns;
|
|
487
|
-
if (input.resume.phase)
|
|
488
|
-
const resumePhase = input.resume.phase;
|
|
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
|
-
}
|
|
402
|
+
if (input.resume.phase) pipeline.seedFromResume(input.resume.phase);
|
|
514
403
|
} else if (pipelineOn) {
|
|
515
404
|
// Goal capture (script, no LLM) + seed phase state + fresh history.
|
|
516
405
|
const captured = captureGoal(runCwd, input.rootPrompt);
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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.`;
|
|
523
|
-
}
|
|
406
|
+
// Fail-soft: fold the failure into the first reset message (never console — corrupts TUI).
|
|
407
|
+
const goalNotice = captured.ok
|
|
408
|
+
? undefined
|
|
409
|
+
: `Note: goal artifact could not be written (${captured.error}); the brief remains only in the system prompt.`;
|
|
524
410
|
// Clarify only when interviews are enabled AND the host wired a callback.
|
|
525
411
|
// Config alone is not enough: without onAskUserQuestion every ask throws and
|
|
526
412
|
// the run would burn maxIterations stuck at clarify (askRounds stays 0).
|
|
@@ -528,8 +414,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
528
414
|
deps.config.askUserQuestion && deps.onAskUserQuestion !== undefined
|
|
529
415
|
? "clarify"
|
|
530
416
|
: "research";
|
|
531
|
-
|
|
532
|
-
history = resetHistoryForPhase(system, phaseState, { goal, notice: goalNotice });
|
|
417
|
+
history = pipeline.seedFresh(startPhase, captured.ok ? captured.value : undefined, goalNotice);
|
|
533
418
|
}
|
|
534
419
|
|
|
535
420
|
// Context: serialize ContextBundle to sandbox-ready JSON array, pass raw strings through.
|
|
@@ -550,9 +435,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
550
435
|
else emitter.emitTurn(i + 1, deps.config.maxIterations);
|
|
551
436
|
|
|
552
437
|
// Apply deferred history reset from a prior advance_phase (fresh session policy).
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
438
|
+
const scheduledReset = pipeline.takePendingReset();
|
|
439
|
+
if (scheduledReset !== undefined) {
|
|
440
|
+
history = scheduledReset;
|
|
556
441
|
pendingReplOutputs = undefined;
|
|
557
442
|
}
|
|
558
443
|
|
|
@@ -588,7 +473,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
588
473
|
pendingReplOutputs = undefined;
|
|
589
474
|
}
|
|
590
475
|
|
|
591
|
-
const gateMsg = deps.config.pipeline ? phaseGatePrompt(
|
|
476
|
+
const gateMsg = deps.config.pipeline ? phaseGatePrompt(pipeline.phase, completedTurns) : undefined;
|
|
592
477
|
const gateUserMsg = gateMsg ? `[${new Date().toISOString()}] ${gateMsg}` : undefined;
|
|
593
478
|
// Phase guidance lives only in resetHistoryForPhase (fresh session) — do not re-inject
|
|
594
479
|
// into every turn prompt (avoids duplication on turn 1 and dead post-transition flags).
|
|
@@ -625,79 +510,27 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
625
510
|
completedTurns = i + 1;
|
|
626
511
|
const final = finalAnswerOf(turn.results);
|
|
627
512
|
if (final != null) {
|
|
628
|
-
// Validate-phase finalize:
|
|
629
|
-
//
|
|
630
|
-
if (pipelineOn &&
|
|
631
|
-
const
|
|
632
|
-
if (
|
|
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") {
|
|
513
|
+
// Validate-phase finalize is gated: the controller measures THIS turn's validation
|
|
514
|
+
// save only, never phase.artifacts (stale after a prior corrective loop).
|
|
515
|
+
if (pipelineOn && pipeline.phase?.current === "validate") {
|
|
516
|
+
const outcome = await pipeline.finalizeInValidate(final);
|
|
517
|
+
if (outcome.kind === "reject") {
|
|
653
518
|
history.push({ role: "assistant", content: turn.response });
|
|
654
|
-
pendingReplOutputs =
|
|
519
|
+
pendingReplOutputs = outcome.error;
|
|
655
520
|
continue;
|
|
656
521
|
}
|
|
657
|
-
|
|
658
|
-
|
|
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 });
|
|
522
|
+
if (outcome.kind === "loop-back") {
|
|
523
|
+
history = outcome.history;
|
|
689
524
|
pendingReplOutputs = undefined;
|
|
690
|
-
pendingHistoryReset = undefined;
|
|
691
525
|
continue;
|
|
692
526
|
}
|
|
693
|
-
if (
|
|
694
|
-
const
|
|
695
|
-
const halted = result(report, i + 1, limits);
|
|
527
|
+
if (outcome.kind === "halt") {
|
|
528
|
+
const halted = result(outcome.report, i + 1, limits);
|
|
696
529
|
await recordTerminal("completed", halted);
|
|
697
530
|
lastAnswer = halted.answer;
|
|
698
531
|
return halted;
|
|
699
532
|
}
|
|
700
|
-
//
|
|
533
|
+
// outcome.kind === "accept" — take the model's final answer
|
|
701
534
|
}
|
|
702
535
|
const done = result(final, i + 1, limits);
|
|
703
536
|
await recordTerminal("completed", done);
|
|
@@ -709,9 +542,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
709
542
|
// Always capture this turn's REPL outputs for the JSONL trail (fidelity).
|
|
710
543
|
const turnReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
|
|
711
544
|
// If advance_phase scheduled a history reset, apply it now (do not pollute fresh history).
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
545
|
+
const nextReset = pipeline.takePendingReset();
|
|
546
|
+
if (nextReset !== undefined) {
|
|
547
|
+
history = nextReset;
|
|
715
548
|
// Fanout/advance result is already embedded in the reset user message — do not
|
|
716
549
|
// also append raw REPL stdout as a next-turn user message.
|
|
717
550
|
pendingReplOutputs = undefined;
|
|
@@ -774,6 +607,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
774
607
|
if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
|
|
775
608
|
emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
|
|
776
609
|
}
|
|
610
|
+
await settleDetached();
|
|
777
611
|
await sandbox?.dispose();
|
|
778
612
|
}
|
|
779
613
|
};
|
package/src/core/gates.ts
CHANGED
|
@@ -135,7 +135,7 @@ export function sectionHasNonEmptyBody(content: string, heading: string): boolea
|
|
|
135
135
|
const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`);
|
|
136
136
|
let inSection = false;
|
|
137
137
|
let seen = false; // first match wins — do not re-enter on a later duplicate heading
|
|
138
|
-
let
|
|
138
|
+
let hasBody = false; // only emptiness matters — never accumulate the body itself
|
|
139
139
|
forEachLineOutsideFences(content, (line) => {
|
|
140
140
|
if (/^##\s+/.test(line)) {
|
|
141
141
|
if (inSection) {
|
|
@@ -148,9 +148,9 @@ export function sectionHasNonEmptyBody(content: string, heading: string): boolea
|
|
|
148
148
|
}
|
|
149
149
|
return;
|
|
150
150
|
}
|
|
151
|
-
if (inSection)
|
|
151
|
+
if (inSection && line.trim().length > 0) hasBody = true;
|
|
152
152
|
});
|
|
153
|
-
return
|
|
153
|
+
return hasBody;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
function escapeRegExp(s: string): string {
|
package/src/core/limits.ts
CHANGED
|
@@ -13,6 +13,24 @@ export interface Limits {
|
|
|
13
13
|
readonly maxErrors?: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** Pick the limit caps out of a config (`RlmConfig` satisfies this structurally). */
|
|
17
|
+
export function limitsFromConfig(config: Limits): Limits {
|
|
18
|
+
return {
|
|
19
|
+
maxBudgetUsd: config.maxBudgetUsd,
|
|
20
|
+
maxTimeoutMs: config.maxTimeoutMs,
|
|
21
|
+
maxTokens: config.maxTokens,
|
|
22
|
+
maxErrors: config.maxErrors,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Point-in-time totals for a run. */
|
|
27
|
+
export interface UsageSnapshot {
|
|
28
|
+
readonly inputTokens: number;
|
|
29
|
+
readonly outputTokens: number;
|
|
30
|
+
readonly costUsd: number;
|
|
31
|
+
readonly durationMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
export class LimitError extends Error {
|
|
17
35
|
constructor(
|
|
18
36
|
public readonly kind: "timeout" | "tokens" | "budget" | "errors",
|
|
@@ -71,7 +89,7 @@ export class LimitGuard {
|
|
|
71
89
|
}
|
|
72
90
|
}
|
|
73
91
|
|
|
74
|
-
usage() {
|
|
92
|
+
usage(): UsageSnapshot {
|
|
75
93
|
return {
|
|
76
94
|
inputTokens: this.inputTokens,
|
|
77
95
|
outputTokens: this.outputTokens,
|