@hicaru/pi-rlm 0.2.0 → 0.2.2
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 +12 -35
- package/README.ru.md +18 -23
- package/README.zh-CN.md +17 -28
- package/package.json +1 -1
- package/src/bridge/library.ts +61 -26
- package/src/bridge/subcall-handlers.ts +382 -0
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +7 -15
- package/src/config/settings.ts +8 -32
- package/src/context/library-context.ts +90 -17
- package/src/core/engine.ts +115 -360
- package/src/core/history.ts +1 -1
- package/src/core/limits.ts +5 -12
- package/src/core/resource-limits.ts +0 -2
- package/src/core/types.ts +3 -36
- package/src/index.ts +49 -10
- package/src/mode/llm-model.ts +54 -0
- package/src/mode/rlm-mode.ts +26 -57
- package/src/prompts/glossary.ts +287 -0
- package/src/prompts/native.ts +127 -0
- package/src/prompts/system.ts +14 -386
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +145 -0
- package/src/sandbox/protocol.ts +14 -69
- package/src/sandbox/py/guards.py +150 -0
- package/src/sandbox/py/retrieval.py +265 -0
- package/src/sandbox/py/tasks.py +116 -0
- package/src/sandbox/py/worker.py +836 -0
- package/src/sandbox/sandbox-manager.ts +33 -6
- package/src/sandbox/sandbox.ts +153 -182
- package/src/text/tokens.ts +29 -3
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +4 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +178 -216
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +10 -16
- package/src/tool/rlm-tool.ts +1 -12
- package/src/tool/subcall-render.ts +15 -3
- package/src/tool/subcall-store.ts +57 -1
- package/src/ui/config-panel.ts +4 -16
- package/src/ui/intro.ts +1 -2
- package/src/ui/model-picker.ts +34 -10
- package/src/ui/status.ts +3 -7
- package/src/util/concurrency.ts +91 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/fallback-todo.ts +0 -137
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/llm-query.ts +0 -156
- package/src/bridge/pi-interactive.ts +0 -41
- package/src/bridge/rlm-query.ts +0 -108
- package/src/core/artifacts.ts +0 -89
- package/src/core/critique.ts +0 -92
- package/src/core/gates.ts +0 -301
- package/src/core/pipeline-handlers.ts +0 -319
- package/src/core/pipeline.ts +0 -268
- package/src/prompts/phases.ts +0 -104
- package/src/sandbox/worker.py +0 -1078
- package/src/state/index.ts +0 -24
- package/src/state/internal.ts +0 -46
- package/src/state/paths.ts +0 -44
- package/src/state/reads.ts +0 -133
- package/src/state/resume.ts +0 -173
- package/src/state/rows.ts +0 -123
- package/src/state/writes.ts +0 -58
package/src/core/engine.ts
CHANGED
|
@@ -5,36 +5,23 @@
|
|
|
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).
|
|
13
8
|
*/
|
|
14
9
|
|
|
15
10
|
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
16
11
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import { buildInteractiveHandlers } from "../bridge/interactive.ts";
|
|
18
12
|
import { buildLibraryHandler } from "../bridge/library.ts";
|
|
19
13
|
import { mergeLibraryIntoContext } from "../context/library-context.ts";
|
|
20
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createSubcallHandlers,
|
|
16
|
+
type Invocation,
|
|
17
|
+
} from "../bridge/subcall-handlers.ts";
|
|
21
18
|
import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
22
|
-
import {
|
|
23
|
-
import { displayModelRef, resolveModelId } from "../config/settings.ts";
|
|
19
|
+
import { resolveModelId } from "../config/settings.ts";
|
|
24
20
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
25
21
|
import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
|
|
26
|
-
import { phaseGuidance } from "../prompts/phases.ts";
|
|
27
22
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
28
23
|
import { PythonSandbox } from "../sandbox/sandbox.ts";
|
|
29
|
-
import {
|
|
30
|
-
phaseGatePrompt,
|
|
31
|
-
type Phase,
|
|
32
|
-
type PhaseState,
|
|
33
|
-
type StageGateData,
|
|
34
|
-
} from "./pipeline.ts";
|
|
35
|
-
import { PipelineController } from "./pipeline-handlers.ts";
|
|
36
|
-
import type { ValidationGateData } from "./gates.ts";
|
|
37
|
-
import { captureGoal, type GoalCapture } from "./artifacts.ts";
|
|
24
|
+
import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
|
|
38
25
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
39
26
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
40
27
|
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
@@ -42,102 +29,41 @@ import { compactHistory, shouldCompact } from "./compaction.ts";
|
|
|
42
29
|
import { appendUserMessage } from "./history.ts";
|
|
43
30
|
import { runTurn } from "./iteration.ts";
|
|
44
31
|
import { type Limits, LimitError, LimitGuard } from "./limits.ts";
|
|
45
|
-
import type {
|
|
46
|
-
import { randomUUID } from "node:crypto";
|
|
47
|
-
import {
|
|
48
|
-
appendRow,
|
|
49
|
-
appendTodoRow,
|
|
50
|
-
generateRunId,
|
|
51
|
-
pruneRuns,
|
|
52
|
-
readLibrarySidecars,
|
|
53
|
-
snapshotPath,
|
|
54
|
-
writeContextSidecar,
|
|
55
|
-
} from "../state/index.ts";
|
|
56
|
-
import { STATE_SCHEMA_VERSION } from "../state/rows.ts";
|
|
57
|
-
import type { PhaseRow, RunHeader } from "../state/rows.ts";
|
|
32
|
+
import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
|
|
58
33
|
import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
|
|
59
34
|
import { formatError } from "../util/errors.ts";
|
|
35
|
+
import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Grace period for detached sub-calls to settle before the run disposes its sandbox.
|
|
39
|
+
* Past it the abort signal (or process exit) is what stops them; waiting longer would
|
|
40
|
+
* hold a finished run open on work whose result nobody can receive.
|
|
41
|
+
*/
|
|
42
|
+
const DETACHED_SETTLE_MS = 5_000;
|
|
60
43
|
|
|
61
44
|
|
|
62
|
-
export interface EngineDeps
|
|
45
|
+
export interface EngineDeps {
|
|
63
46
|
readonly model: Model<Api>;
|
|
64
|
-
readonly
|
|
47
|
+
readonly llmModel: Model<Api>;
|
|
65
48
|
readonly registry: ModelRegistry;
|
|
66
49
|
readonly config: RlmConfig;
|
|
67
50
|
readonly limits?: Limits;
|
|
51
|
+
/** Session-wide sub-call admission, shared with the repl() tool. Private one if omitted. */
|
|
52
|
+
readonly gates?: SubcallGates;
|
|
68
53
|
readonly signal?: AbortSignal;
|
|
69
54
|
/** Live RlmDetails reporting via onUpdate. Required — replaces SubcallObserver. */
|
|
70
55
|
readonly emitter: RlmEmitter;
|
|
71
56
|
/** Called with each completion's usage (root + sub-LLM) for cost/token rollups. */
|
|
72
57
|
readonly onUsage?: (usage: Usage, role: "root" | "sub") => void;
|
|
73
|
-
/** Run-state persistence handle. undefined ⇒ persistence off. */
|
|
74
|
-
readonly runState?: { readonly cwd: string; readonly dir: string; readonly snapshot: boolean };
|
|
75
58
|
/** Test-only: override model completion (scripted multi-turn responses). */
|
|
76
59
|
readonly complete?: import("./iteration.ts").CompleteFn;
|
|
77
60
|
}
|
|
78
61
|
|
|
79
|
-
/** Optional payload for a fresh-session history reset at a phase boundary. */
|
|
80
|
-
export interface PhaseHistoryOptions {
|
|
81
|
-
readonly goal?: GoalCapture;
|
|
82
|
-
readonly validation?: ValidationGateData;
|
|
83
|
-
/** Engine notice folded into the first user message (no console I/O). */
|
|
84
|
-
readonly notice?: string;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Fresh-session policy: at each phase boundary the conversation is replaced;
|
|
89
|
-
* artifacts (paths) are the only channel. REPL variables persist — the transition
|
|
90
|
-
* message tells the model context survives in the sandbox, not in chat.
|
|
91
|
-
*/
|
|
92
|
-
export function resetHistoryForPhase(
|
|
93
|
-
system: string,
|
|
94
|
-
state: PhaseState,
|
|
95
|
-
options: PhaseHistoryOptions = {},
|
|
96
|
-
): ChatMsg[] {
|
|
97
|
-
const { goal, validation, notice } = options;
|
|
98
|
-
const parts: string[] = [
|
|
99
|
-
`You are entering the '${state.current}' phase.`,
|
|
100
|
-
];
|
|
101
|
-
if (notice) parts.push(notice);
|
|
102
|
-
if (goal) {
|
|
103
|
-
parts.push(`The user's verbatim brief: read ${goal.goalPath} from the REPL (open()).`);
|
|
104
|
-
parts.push(`Pre-run dirty baseline (exclude from delta judgment): ${goal.baselinePath}`);
|
|
105
|
-
}
|
|
106
|
-
for (const [p, ref] of Object.entries(state.artifacts)) {
|
|
107
|
-
if (ref === undefined) continue;
|
|
108
|
-
parts.push(
|
|
109
|
-
ref.status === "superseded"
|
|
110
|
-
? `Superseded artifact from '${p}' (rejected by validation): ${ref.path} — read it and the validation before re-planning; do not repeat its blockers.`
|
|
111
|
-
: `Artifact from '${p}': ${ref.path}`,
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
if (validation) {
|
|
115
|
-
parts.push(
|
|
116
|
-
`Previous validation found ${validation.blockersCount} blocker(s) — read the validation artifact and address every blocker in the revised plan.`,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
parts.push(phaseGuidance(state.current));
|
|
120
|
-
parts.push("Your REPL variables persist; the chat history was reset to keep your window small.");
|
|
121
|
-
return [
|
|
122
|
-
{ role: "system", content: system },
|
|
123
|
-
{ role: "user", content: parts.join("\n") },
|
|
124
|
-
];
|
|
125
|
-
}
|
|
126
|
-
|
|
127
62
|
/** Build a `runRlm` bound to the given deps. The returned function is reused for recursion. */
|
|
128
63
|
export function createEngine(deps: EngineDeps): RunRlm {
|
|
129
64
|
const { emitter } = deps;
|
|
130
65
|
const run: RunRlm = async (input: RlmInput): Promise<RlmResult> => {
|
|
131
|
-
const
|
|
132
|
-
const persist = input.depth === 0 && deps.runState !== undefined;
|
|
133
|
-
const runCwd = deps.runState?.cwd ?? process.cwd();
|
|
134
|
-
// Compute runId early for run-state correlation on resume.
|
|
135
|
-
const runId = persist
|
|
136
|
-
? (input.resume ? input.resume.header.runId : generateRunId())
|
|
137
|
-
: undefined;
|
|
138
|
-
// I4: session-scoped pickle trust — nonce prevents cross-session snapshot replay.
|
|
139
|
-
// On resume, sessionNonce is undefined → no snapshots, history-only replay.
|
|
140
|
-
const sessionNonce = persist && !input.resume ? randomUUID() : undefined;
|
|
66
|
+
const runCwd = process.cwd();
|
|
141
67
|
// For depth > 0, input.parentNodeId is the subcall ID created by the parent's rlm-query bridge.
|
|
142
68
|
// For depth 0, input.parentNodeId is undefined — engine uses root-level bridge methods.
|
|
143
69
|
const selfReportId = input.depth === 0 ? undefined : input.parentNodeId;
|
|
@@ -162,107 +88,96 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
162
88
|
const model = overrideModel ?? deps.model;
|
|
163
89
|
|
|
164
90
|
// Create LimitGuard BEFORE the bridge so sub-LLM usage feeds into it.
|
|
165
|
-
// Children inherit the parent's remaining
|
|
166
|
-
//
|
|
167
|
-
// CA: seed the clock on resume so resumed runs don't get a fresh timeout budget.
|
|
91
|
+
// Children inherit the parent's remaining timeout (propagated as remaining amount, not
|
|
92
|
+
// the full original cap).
|
|
168
93
|
const limits = new LimitGuard({
|
|
169
|
-
maxBudgetUsd: input.remainingBudgetUsd ?? deps.limits?.maxBudgetUsd,
|
|
170
94
|
maxTimeoutMs: input.remainingTimeoutMs ?? deps.limits?.maxTimeoutMs,
|
|
171
95
|
maxErrors: deps.limits?.maxErrors,
|
|
172
96
|
maxTokens: deps.limits?.maxTokens,
|
|
173
|
-
}, input.resume?.usageSeed.durationMs ?? 0);
|
|
174
|
-
const remainingBudget = (): { readonly budgetUsd?: number; readonly timeoutMs?: number } => ({
|
|
175
|
-
budgetUsd: limits.remainingBudgetUsd(),
|
|
176
|
-
timeoutMs: limits.remainingTimeoutMs(),
|
|
177
97
|
});
|
|
178
98
|
|
|
179
|
-
|
|
180
|
-
|
|
99
|
+
// One Invocation for the whole run: this engine owns exactly one sandbox at one depth,
|
|
100
|
+
// and its emitter and LimitGuard outlive every sub-call it services — including
|
|
101
|
+
// detached ones, which is why the headless path needs no session registry.
|
|
102
|
+
const invocation: Invocation = {
|
|
103
|
+
emitter,
|
|
104
|
+
parentId: selfReportId,
|
|
105
|
+
depth: input.depth,
|
|
106
|
+
limits: {
|
|
107
|
+
remainingTimeoutMs: () => limits.remainingTimeoutMs(),
|
|
108
|
+
addUsage: (u) => {
|
|
109
|
+
limits.addUsage(u);
|
|
110
|
+
deps.onUsage?.(u, "sub");
|
|
111
|
+
},
|
|
112
|
+
addRaw: (costUsd, inputTokens, outputTokens) => {
|
|
113
|
+
limits.addRaw(costUsd, inputTokens, outputTokens);
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
// Detached work must not outlive the sandbox we dispose in `finally`: track it so the
|
|
118
|
+
// run can settle or abort it first (a child engine left running would keep spending).
|
|
119
|
+
let detachedInFlight = 0;
|
|
120
|
+
let detachedIdle: (() => void) | undefined;
|
|
121
|
+
const subcalls = createSubcallHandlers({
|
|
122
|
+
resolve: () => invocation,
|
|
123
|
+
gates: deps.gates
|
|
124
|
+
?? createSubcallGates(deps.config.maxConcurrentSubcalls, deps.config.maxConcurrentChildren),
|
|
181
125
|
registry: deps.registry,
|
|
182
|
-
|
|
126
|
+
getLlmModel: () => deps.llmModel,
|
|
127
|
+
getModel: () => model,
|
|
128
|
+
getConfig: () => deps.config,
|
|
183
129
|
signal: deps.signal,
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
modelLabel: (override) => displayModelRef(deps.registry, override, model),
|
|
198
|
-
emitter: () => emitter,
|
|
199
|
-
parentNodeId: () => selfReportId,
|
|
200
|
-
remainingBudget,
|
|
201
|
-
onChildUsage: (costUsd, inputTokens, outputTokens) => {
|
|
202
|
-
limits.addRaw(costUsd, inputTokens, outputTokens);
|
|
130
|
+
runChild: run,
|
|
131
|
+
// Read lazily: a library loaded on turn 3 must reach a child spawned on turn 4. Safe
|
|
132
|
+
// despite being wired before liveContext is assigned — children can only spawn from an
|
|
133
|
+
// interrupt during runTurn, which is strictly after loadContext below.
|
|
134
|
+
getChildContext: () => liveContext,
|
|
135
|
+
trackDetached: async (task) => {
|
|
136
|
+
detachedInFlight += 1;
|
|
137
|
+
try {
|
|
138
|
+
return await task();
|
|
139
|
+
} finally {
|
|
140
|
+
detachedInFlight -= 1;
|
|
141
|
+
if (detachedInFlight === 0) detachedIdle?.();
|
|
142
|
+
}
|
|
203
143
|
},
|
|
204
144
|
});
|
|
145
|
+
/** Wait (bounded) for detached work before the sandbox goes away. */
|
|
146
|
+
const settleDetached = async (): Promise<void> => {
|
|
147
|
+
if (detachedInFlight === 0) return;
|
|
148
|
+
await new Promise<void>((resolve) => {
|
|
149
|
+
detachedIdle = resolve;
|
|
150
|
+
setTimeout(resolve, DETACHED_SETTLE_MS).unref?.();
|
|
151
|
+
});
|
|
152
|
+
detachedIdle = undefined;
|
|
153
|
+
};
|
|
205
154
|
let sandbox: PythonSandbox | undefined;
|
|
155
|
+
/**
|
|
156
|
+
* This run's live context: the repo pack plus every library loaded so far. Children inherit
|
|
157
|
+
* it, so it must grow when load_library appends (see the library handler's onLoaded below).
|
|
158
|
+
*
|
|
159
|
+
* Run-scoped on purpose — recursion means N of these are live at once, and a module-level
|
|
160
|
+
* "current context" would hand a depth-3 child its cousin's world.
|
|
161
|
+
*/
|
|
162
|
+
let liveContext: unknown = null;
|
|
163
|
+
/**
|
|
164
|
+
* This run's hold on the serialized context file. Kept for the whole run so every child that
|
|
165
|
+
* inherits the same payload reuses one file instead of re-serializing the repository.
|
|
166
|
+
*/
|
|
167
|
+
let contextPin: PinnedContext | undefined;
|
|
168
|
+
/** Re-pin after the payload changes identity (mergeLibraryIntoContext returns a new array). */
|
|
169
|
+
const repinLiveContext = async (): Promise<void> => {
|
|
170
|
+
const previous = contextPin;
|
|
171
|
+
contextPin = await pinContext(liveContext);
|
|
172
|
+
await previous?.release();
|
|
173
|
+
};
|
|
206
174
|
let best = "";
|
|
207
175
|
let lastAnswer = "";
|
|
208
176
|
let compactions = 0;
|
|
209
177
|
let completedTurns = 0;
|
|
210
|
-
/** Owns all pipeline state (phase, per-phase latest save, ask rounds, pending reset). */
|
|
211
|
-
let pipeline: PipelineController | undefined;
|
|
212
178
|
let nodeStatus: "done" | "error" = "done";
|
|
213
|
-
let persistOn = persist;
|
|
214
|
-
if (persist && deps.runState && !input.resume && runId) {
|
|
215
|
-
const json = typeof input.context !== "string";
|
|
216
|
-
const sidecarOk = await writeContextSidecar(deps.runState.cwd, deps.runState.dir, runId, input.context, json);
|
|
217
|
-
if (!sidecarOk) {
|
|
218
|
-
persistOn = false; // QC: skip header if sidecar failed — prevents orphan trail referencing non-existent context
|
|
219
|
-
} else {
|
|
220
|
-
const header: RunHeader = {
|
|
221
|
-
kind: "header", v: STATE_SCHEMA_VERSION, runId, ts: nowIso(),
|
|
222
|
-
rootPrompt: input.rootPrompt,
|
|
223
|
-
context: { type: contextTypeLabel(input.context), chars: contextLength(input.context), json },
|
|
224
|
-
models: { model: model.id, worker: deps.workerModel.id },
|
|
225
|
-
meta: { maxIterations: deps.config.maxIterations, maxDepth: deps.config.maxDepth, orchestrator: deps.config.orchestrator, pipeline: deps.config.pipeline },
|
|
226
|
-
};
|
|
227
|
-
persistOn = await appendRow(deps.runState.cwd, deps.runState.dir, runId, header);
|
|
228
|
-
}
|
|
229
|
-
await pruneRuns(deps.runState.cwd, deps.runState.dir, deps.config.runLog?.maxRuns ?? 50); // Ops: retention (always — cleanup even if sidecar failed)
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const recordTerminal = async (status: "completed" | "finalized" | "aborted" | "stopped", r: RlmResult): Promise<boolean> => {
|
|
233
|
-
if (!persistOn || !runId || !deps.runState) return false;
|
|
234
|
-
return await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
|
|
235
|
-
kind: "terminal", ts: nowIso(), status, answer: r.answer, iterations: r.iterations,
|
|
236
|
-
usage: { costUsd: r.costUsd, inputTokens: r.inputTokens, outputTokens: r.outputTokens },
|
|
237
|
-
});
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
const persistPhaseRow = async (
|
|
241
|
-
state: PhaseState,
|
|
242
|
-
artifactPath: string | undefined,
|
|
243
|
-
artifactPhase: Phase | undefined,
|
|
244
|
-
gateData: StageGateData | undefined,
|
|
245
|
-
supersededPath?: string,
|
|
246
|
-
): Promise<void> => {
|
|
247
|
-
if (!persistOn || !runId || !deps.runState) return;
|
|
248
|
-
const row: PhaseRow = {
|
|
249
|
-
kind: "phase",
|
|
250
|
-
turn: completedTurns + 1,
|
|
251
|
-
ts: nowIso(),
|
|
252
|
-
phase: state.current,
|
|
253
|
-
summary: state.summary,
|
|
254
|
-
artifactPath,
|
|
255
|
-
artifactPhase,
|
|
256
|
-
blockersCount: gateData?.kind === "validation" ? gateData.validation.blockersCount : undefined,
|
|
257
|
-
backwardJumps: state.backwardJumps,
|
|
258
|
-
supersededPath,
|
|
259
|
-
};
|
|
260
|
-
const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, row);
|
|
261
|
-
if (!ok) persistOn = false;
|
|
262
|
-
};
|
|
263
179
|
|
|
264
180
|
try {
|
|
265
|
-
const pipelineOn = input.depth === 0 && deps.config.pipeline;
|
|
266
181
|
const meta = {
|
|
267
182
|
contextType: contextTypeLabel(input.context),
|
|
268
183
|
contextChars: contextLength(input.context),
|
|
@@ -272,74 +187,23 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
272
187
|
const system = buildRlmSystemPrompt(meta, {
|
|
273
188
|
orchestrator: deps.config.orchestrator,
|
|
274
189
|
recursion: input.depth + 1 < deps.config.maxDepth,
|
|
275
|
-
askUserQuestion: deps.config.askUserQuestion && input.depth === 0,
|
|
276
|
-
todo: deps.config.todo,
|
|
277
|
-
pipeline: deps.config.pipeline && input.depth === 0,
|
|
278
190
|
maxPromptChars: deps.config.maxPromptChars,
|
|
279
191
|
libraryLoader: deps.config.libraryLoader,
|
|
192
|
+
child: input.depth > 0,
|
|
280
193
|
});
|
|
281
194
|
|
|
282
|
-
pipeline = new PipelineController({
|
|
283
|
-
runCwd,
|
|
284
|
-
maxBackwardJumps: deps.config.maxBackwardJumps,
|
|
285
|
-
emitter,
|
|
286
|
-
completedTurns: () => completedTurns,
|
|
287
|
-
resetHistoryForPhase: (state, options) => resetHistoryForPhase(system, state, options),
|
|
288
|
-
persistPhaseRow,
|
|
289
|
-
});
|
|
290
|
-
const phaseHandlers = pipelineOn ? pipeline.handlers() : {};
|
|
291
|
-
const baseAsk = deps.config.askUserQuestion ? deps.onAskUserQuestion : undefined;
|
|
292
|
-
const interactiveHandlers = buildInteractiveHandlers({
|
|
293
|
-
onAskUserQuestion: baseAsk
|
|
294
|
-
? async (questions) => {
|
|
295
|
-
const answers = await baseAsk(questions);
|
|
296
|
-
// Count only successfully serviced root-depth rounds (handler already rejects depth>0).
|
|
297
|
-
pipeline?.noteAskRound();
|
|
298
|
-
return answers;
|
|
299
|
-
}
|
|
300
|
-
: undefined,
|
|
301
|
-
onTodo: deps.config.todo ? deps.onTodo : undefined,
|
|
302
|
-
onTodoRow: async (action, params, todoResult) => {
|
|
303
|
-
if (!persistOn || !runId || !deps.runState) return;
|
|
304
|
-
const ok = await appendTodoRow(deps.runState.cwd, deps.runState.dir, runId, {
|
|
305
|
-
turn: completedTurns + 1, ts: nowIso(), action, params, result: todoResult,
|
|
306
|
-
});
|
|
307
|
-
if (!ok) persistOn = false;
|
|
308
|
-
},
|
|
309
|
-
emitter,
|
|
310
|
-
depth: input.depth,
|
|
311
|
-
parentId: selfReportId,
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
const restoredSlots = input.resume && deps.runState && runId
|
|
315
|
-
? await readLibrarySidecars(deps.runState.cwd, deps.runState.dir, runId)
|
|
316
|
-
: [];
|
|
317
|
-
// Seed host-side idempotency from restored sidecars so re-load is a no-op.
|
|
318
|
-
const restoredPrefixes: string[] = [];
|
|
319
|
-
for (const slot of restoredSlots) {
|
|
320
|
-
if (!Array.isArray(slot.payload) || slot.payload.length === 0) continue;
|
|
321
|
-
const first = slot.payload[0];
|
|
322
|
-
if (first === null || typeof first !== "object") continue;
|
|
323
|
-
const path = typeof (first as { path?: unknown }).path === "string"
|
|
324
|
-
? (first as { path: string }).path
|
|
325
|
-
: "";
|
|
326
|
-
const m = path.match(/^(lib\/[^/]+\/)/);
|
|
327
|
-
if (m?.[1] !== undefined) restoredPrefixes.push(m[1]);
|
|
328
|
-
}
|
|
329
195
|
const libraryHandlers = deps.config.libraryLoader
|
|
330
196
|
? buildLibraryHandler({
|
|
331
197
|
cwd: runCwd,
|
|
332
198
|
emitter,
|
|
333
199
|
parentId: selfReportId,
|
|
334
200
|
signal: deps.signal,
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
payload, typeof payload !== "string", index,
|
|
342
|
-
);
|
|
201
|
+
getContext: () => liveContext,
|
|
202
|
+
onLoaded: async (payload) => {
|
|
203
|
+
// The accumulator is what children inherit, which is what makes inheritance
|
|
204
|
+
// transitive — grandchildren see the library too.
|
|
205
|
+
liveContext = mergeLibraryIntoContext(liveContext, payload);
|
|
206
|
+
await repinLiveContext();
|
|
343
207
|
},
|
|
344
208
|
}).handlers
|
|
345
209
|
: {};
|
|
@@ -352,85 +216,37 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
352
216
|
signal: deps.signal,
|
|
353
217
|
initTimeoutMs: deps.config.sandboxInitTimeoutMs,
|
|
354
218
|
maxPromptChars: deps.config.maxPromptChars,
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
handlers: { ...llm, ...rlm, ...phaseHandlers, ...interactiveHandlers, ...libraryHandlers },
|
|
219
|
+
awaitTimeoutS: Math.round(deps.config.requestTimeoutMs / 1000),
|
|
220
|
+
handlers: { ...subcalls, ...libraryHandlers },
|
|
358
221
|
});
|
|
359
222
|
|
|
360
|
-
let history: ChatMsg[] =
|
|
361
|
-
let pendingReplOutputs: string | undefined
|
|
362
|
-
const startTurn = input.resume?.completedTurns ?? 0;
|
|
363
|
-
if (input.resume) {
|
|
364
|
-
limits.addRaw(input.resume.usageSeed.costUsd, input.resume.usageSeed.inputTokens, input.resume.usageSeed.outputTokens);
|
|
365
|
-
best = input.resume.best;
|
|
366
|
-
compactions = input.resume.compactions;
|
|
367
|
-
completedTurns = input.resume.completedTurns;
|
|
368
|
-
if (input.resume.phase) pipeline.seedFromResume(input.resume.phase);
|
|
369
|
-
} else if (pipelineOn) {
|
|
370
|
-
// Goal capture (script, no LLM) + seed phase state + fresh history.
|
|
371
|
-
const captured = captureGoal(runCwd, input.rootPrompt);
|
|
372
|
-
// Fail-soft: fold the failure into the first reset message (never console — corrupts TUI).
|
|
373
|
-
const goalNotice = captured.ok
|
|
374
|
-
? undefined
|
|
375
|
-
: `Note: goal artifact could not be written (${captured.error}); the brief remains only in the system prompt.`;
|
|
376
|
-
// Clarify only when interviews are enabled AND the host wired a callback.
|
|
377
|
-
// Config alone is not enough: without onAskUserQuestion every ask throws and
|
|
378
|
-
// the run would burn maxIterations stuck at clarify (askRounds stays 0).
|
|
379
|
-
const startPhase =
|
|
380
|
-
deps.config.askUserQuestion && deps.onAskUserQuestion !== undefined
|
|
381
|
-
? "clarify"
|
|
382
|
-
: "research";
|
|
383
|
-
history = pipeline.seedFresh(startPhase, captured.ok ? captured.value : undefined, goalNotice);
|
|
384
|
-
}
|
|
223
|
+
let history: ChatMsg[] = [{ role: "system", content: system }];
|
|
224
|
+
let pendingReplOutputs: string | undefined;
|
|
385
225
|
|
|
386
226
|
// Context: serialize ContextBundle to sandbox-ready JSON array, pass raw strings through.
|
|
387
|
-
|
|
388
|
-
let contextValue: unknown =
|
|
227
|
+
liveContext =
|
|
389
228
|
typeof input.context === "object" && input.context !== null && "files" in input.context
|
|
390
229
|
? serializeForSandbox(input.context as ContextBundle)
|
|
391
230
|
: input.context;
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
await sandbox.loadContext(contextValue);
|
|
396
|
-
if (input.resume?.snapshotTurn !== undefined && deps.runState && runId && sessionNonce) // R-C1: restore only for same-session (sessionNonce present)
|
|
397
|
-
await sandbox.restore(snapshotPath(deps.runState.cwd, deps.runState.dir, runId, input.resume.snapshotTurn), sessionNonce);
|
|
398
|
-
for (let i = startTurn; i < deps.config.maxIterations; i++) {
|
|
231
|
+
contextPin = await pinContext(liveContext);
|
|
232
|
+
await sandbox.loadContextPinned(contextPin);
|
|
233
|
+
for (let i = 0; i < deps.config.maxIterations; i++) {
|
|
399
234
|
limits.checkTimeout();
|
|
400
235
|
if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
|
|
401
236
|
else emitter.emitTurn(i + 1, deps.config.maxIterations);
|
|
402
237
|
|
|
403
|
-
// Apply deferred history reset from a prior advance_phase (fresh session policy).
|
|
404
|
-
const scheduledReset = pipeline.takePendingReset();
|
|
405
|
-
if (scheduledReset !== undefined) {
|
|
406
|
-
history = scheduledReset;
|
|
407
|
-
pendingReplOutputs = undefined;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
238
|
if (deps.config.compaction) {
|
|
411
239
|
const compactionDeps = {
|
|
412
240
|
// Summarisation is done by the cheap worker model; the threshold stays on the
|
|
413
241
|
// root model's context window (that is the window the history fills each turn).
|
|
414
|
-
model: deps.
|
|
242
|
+
model: deps.llmModel,
|
|
415
243
|
registry: deps.registry,
|
|
416
244
|
contextWindow: model.contextWindow,
|
|
417
245
|
thresholdPct: deps.config.compactionThresholdPct,
|
|
418
246
|
signal: deps.signal,
|
|
419
247
|
};
|
|
420
248
|
if (shouldCompact(history, compactionDeps)) {
|
|
421
|
-
|
|
422
|
-
let compactionUsage = { costUsd: 0, inputTokens: 0, outputTokens: 0 };
|
|
423
|
-
history = await compactHistory(history, compactionDeps, ++compactions, (u) => {
|
|
424
|
-
limits.addUsage(u);
|
|
425
|
-
compactionUsage = { costUsd: compactionUsage.costUsd + u.cost.total, inputTokens: compactionUsage.inputTokens + u.input, outputTokens: compactionUsage.outputTokens + u.output }; // CC: accumulate
|
|
426
|
-
});
|
|
427
|
-
if (persistOn && runId && deps.runState && history !== prevHistoryRef) {
|
|
428
|
-
const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
|
|
429
|
-
kind: "compaction", turn: i + 1, ts: nowIso(), history,
|
|
430
|
-
usage: compactionUsage,
|
|
431
|
-
});
|
|
432
|
-
if (!ok) persistOn = false; // QC: disable persistence on first failure (match turn-row pattern)
|
|
433
|
-
}
|
|
249
|
+
history = await compactHistory(history, compactionDeps, ++compactions, (u) => limits.addUsage(u));
|
|
434
250
|
}
|
|
435
251
|
}
|
|
436
252
|
|
|
@@ -439,14 +255,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
439
255
|
pendingReplOutputs = undefined;
|
|
440
256
|
}
|
|
441
257
|
|
|
442
|
-
|
|
443
|
-
const gateUserMsg = gateMsg ? `[${new Date().toISOString()}] ${gateMsg}` : undefined;
|
|
444
|
-
// Phase guidance lives only in resetHistoryForPhase (fresh session) — do not re-inject
|
|
445
|
-
// into every turn prompt (avoids duplication on turn 1 and dead post-transition flags).
|
|
446
|
-
appendUserMessage(
|
|
447
|
-
history,
|
|
448
|
-
buildTurnPrompt(i, deps.config.maxIterations, gateUserMsg),
|
|
449
|
-
);
|
|
258
|
+
appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations));
|
|
450
259
|
|
|
451
260
|
// rootSampling fields win; smartReasoning is the default reasoning when not overridden.
|
|
452
261
|
const rootSampling: Sampling = {
|
|
@@ -476,86 +285,29 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
476
285
|
completedTurns = i + 1;
|
|
477
286
|
const final = finalAnswerOf(turn.results);
|
|
478
287
|
if (final != null) {
|
|
479
|
-
// Validate-phase finalize is gated: the controller measures THIS turn's validation
|
|
480
|
-
// save only, never phase.artifacts (stale after a prior corrective loop).
|
|
481
|
-
if (pipelineOn && pipeline.phase?.current === "validate") {
|
|
482
|
-
const outcome = await pipeline.finalizeInValidate(final);
|
|
483
|
-
if (outcome.kind === "reject") {
|
|
484
|
-
history.push({ role: "assistant", content: turn.response });
|
|
485
|
-
pendingReplOutputs = outcome.error;
|
|
486
|
-
continue;
|
|
487
|
-
}
|
|
488
|
-
if (outcome.kind === "loop-back") {
|
|
489
|
-
history = outcome.history;
|
|
490
|
-
pendingReplOutputs = undefined;
|
|
491
|
-
continue;
|
|
492
|
-
}
|
|
493
|
-
if (outcome.kind === "halt") {
|
|
494
|
-
const halted = result(outcome.report, i + 1, limits);
|
|
495
|
-
await recordTerminal("completed", halted);
|
|
496
|
-
lastAnswer = halted.answer;
|
|
497
|
-
return halted;
|
|
498
|
-
}
|
|
499
|
-
// outcome.kind === "accept" — take the model's final answer
|
|
500
|
-
}
|
|
501
288
|
const done = result(final, i + 1, limits);
|
|
502
|
-
await recordTerminal("completed", done);
|
|
503
289
|
lastAnswer = done.answer;
|
|
504
290
|
return done;
|
|
505
291
|
}
|
|
506
292
|
|
|
507
293
|
limits.observe(turnHadError(turn.results));
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
// If advance_phase scheduled a history reset, apply it now (do not pollute fresh history).
|
|
511
|
-
const nextReset = pipeline.takePendingReset();
|
|
512
|
-
if (nextReset !== undefined) {
|
|
513
|
-
history = nextReset;
|
|
514
|
-
// Fanout/advance result is already embedded in the reset user message — do not
|
|
515
|
-
// also append raw REPL stdout as a next-turn user message.
|
|
516
|
-
pendingReplOutputs = undefined;
|
|
517
|
-
} else {
|
|
518
|
-
history.push({ role: "assistant", content: turn.response });
|
|
519
|
-
pendingReplOutputs = turnReplOutputs;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
if (persistOn && runId && deps.runState) {
|
|
523
|
-
const pklPath = snapshotPath(deps.runState.cwd, deps.runState.dir, runId, i + 1);
|
|
524
|
-
const snapOk = deps.runState.snapshot && sandbox && sessionNonce
|
|
525
|
-
? await sandbox.snapshot(pklPath, sessionNonce)
|
|
526
|
-
: false;
|
|
527
|
-
const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
|
|
528
|
-
kind: "turn", turn: i + 1, ts: nowIso(),
|
|
529
|
-
response: turn.response,
|
|
530
|
-
// Trail keeps the real REPL output even when history was reset (issue #9).
|
|
531
|
-
replOutputs: turnReplOutputs || undefined,
|
|
532
|
-
answerContent: answerContent || undefined,
|
|
533
|
-
error: turnHadError(turn.results),
|
|
534
|
-
usage: { costUsd: turn.usage.cost.total, inputTokens: turn.usage.input, outputTokens: turn.usage.output }, // B2: Usage has .input/.output, not .inputTokens/.outputTokens
|
|
535
|
-
cumulativeDurationMs: limits.usage().durationMs, // B3: required by TurnRow, seeds LimitGuard clock on resume (CA)
|
|
536
|
-
snapshotOk: snapOk,
|
|
537
|
-
});
|
|
538
|
-
if (!ok) persistOn = false;
|
|
539
|
-
// No finalizeSnapshot — snapshot is atomic (os.rename inside worker.py)
|
|
540
|
-
}
|
|
294
|
+
history.push({ role: "assistant", content: turn.response });
|
|
295
|
+
pendingReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
|
|
541
296
|
}
|
|
542
297
|
if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
|
|
543
298
|
const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
|
|
544
|
-
await recordTerminal("finalized", finalized);
|
|
545
299
|
lastAnswer = finalized.answer;
|
|
546
300
|
return finalized;
|
|
547
301
|
} catch (err) {
|
|
548
302
|
// Abort is a user action — resolve with the best partial, not an error.
|
|
549
303
|
if (deps.signal?.aborted) {
|
|
550
304
|
const aborted = result(best.trim() || "(aborted)", completedTurns, limits);
|
|
551
|
-
await recordTerminal("aborted", aborted);
|
|
552
305
|
lastAnswer = aborted.answer;
|
|
553
306
|
return aborted;
|
|
554
307
|
}
|
|
555
308
|
if (err instanceof LimitError) {
|
|
556
309
|
nodeStatus = "error";
|
|
557
310
|
const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits);
|
|
558
|
-
await recordTerminal("stopped", stopped);
|
|
559
311
|
lastAnswer = stopped.answer;
|
|
560
312
|
return stopped;
|
|
561
313
|
}
|
|
@@ -573,6 +325,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
573
325
|
if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
|
|
574
326
|
emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
|
|
575
327
|
}
|
|
328
|
+
// Settle detached work FIRST: a child still running may be about to pin this same payload.
|
|
329
|
+
await settleDetached();
|
|
330
|
+
await contextPin?.release();
|
|
576
331
|
await sandbox?.dispose();
|
|
577
332
|
}
|
|
578
333
|
};
|
package/src/core/history.ts
CHANGED