@hicaru/pi-rlm 0.2.1 → 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 +63 -17
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +6 -17
- package/src/config/settings.ts +8 -32
- package/src/context/library-context.ts +90 -17
- package/src/core/engine.ts +55 -335
- 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 +23 -12
- 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 -407
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +145 -0
- package/src/sandbox/protocol.ts +8 -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/{worker.py → py/worker.py} +76 -696
- package/src/sandbox/sandbox-manager.ts +13 -0
- package/src/sandbox/sandbox.ts +99 -193
- package/src/text/tokens.ts +29 -3
- package/src/tool/repl-details.ts +2 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +37 -159
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +0 -14
- package/src/tool/rlm-tool.ts +1 -12
- 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 +9 -5
- package/src/bridge/fallback-todo.ts +0 -148
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/pi-interactive.ts +0 -41
- 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/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,16 +5,10 @@
|
|
|
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
14
|
import {
|
|
@@ -25,18 +19,9 @@ import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
|
25
19
|
import { resolveModelId } from "../config/settings.ts";
|
|
26
20
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
27
21
|
import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
|
|
28
|
-
import { phaseGuidance } from "../prompts/phases.ts";
|
|
29
22
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
30
23
|
import { PythonSandbox } from "../sandbox/sandbox.ts";
|
|
31
|
-
import {
|
|
32
|
-
phaseGatePrompt,
|
|
33
|
-
type Phase,
|
|
34
|
-
type PhaseState,
|
|
35
|
-
type StageGateData,
|
|
36
|
-
} from "./pipeline.ts";
|
|
37
|
-
import { PipelineController } from "./pipeline-handlers.ts";
|
|
38
|
-
import type { ValidationGateData } from "./gates.ts";
|
|
39
|
-
import { captureGoal, type GoalCapture } from "./artifacts.ts";
|
|
24
|
+
import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
|
|
40
25
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
41
26
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
42
27
|
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
@@ -44,19 +29,7 @@ import { compactHistory, shouldCompact } from "./compaction.ts";
|
|
|
44
29
|
import { appendUserMessage } from "./history.ts";
|
|
45
30
|
import { runTurn } from "./iteration.ts";
|
|
46
31
|
import { type Limits, LimitError, LimitGuard } from "./limits.ts";
|
|
47
|
-
import type {
|
|
48
|
-
import { randomUUID } from "node:crypto";
|
|
49
|
-
import {
|
|
50
|
-
appendRow,
|
|
51
|
-
appendTodoRow,
|
|
52
|
-
generateRunId,
|
|
53
|
-
pruneRuns,
|
|
54
|
-
readLibrarySidecars,
|
|
55
|
-
snapshotPath,
|
|
56
|
-
writeContextSidecar,
|
|
57
|
-
} from "../state/index.ts";
|
|
58
|
-
import { STATE_SCHEMA_VERSION } from "../state/rows.ts";
|
|
59
|
-
import type { PhaseRow, RunHeader } from "../state/rows.ts";
|
|
32
|
+
import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
|
|
60
33
|
import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
|
|
61
34
|
import { formatError } from "../util/errors.ts";
|
|
62
35
|
import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
|
|
@@ -69,9 +42,9 @@ import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
|
|
|
69
42
|
const DETACHED_SETTLE_MS = 5_000;
|
|
70
43
|
|
|
71
44
|
|
|
72
|
-
export interface EngineDeps
|
|
45
|
+
export interface EngineDeps {
|
|
73
46
|
readonly model: Model<Api>;
|
|
74
|
-
readonly
|
|
47
|
+
readonly llmModel: Model<Api>;
|
|
75
48
|
readonly registry: ModelRegistry;
|
|
76
49
|
readonly config: RlmConfig;
|
|
77
50
|
readonly limits?: Limits;
|
|
@@ -82,74 +55,15 @@ export interface EngineDeps extends InteractiveDeps {
|
|
|
82
55
|
readonly emitter: RlmEmitter;
|
|
83
56
|
/** Called with each completion's usage (root + sub-LLM) for cost/token rollups. */
|
|
84
57
|
readonly onUsage?: (usage: Usage, role: "root" | "sub") => void;
|
|
85
|
-
/** Run-state persistence handle. undefined ⇒ persistence off. */
|
|
86
|
-
readonly runState?: { readonly cwd: string; readonly dir: string; readonly snapshot: boolean };
|
|
87
58
|
/** Test-only: override model completion (scripted multi-turn responses). */
|
|
88
59
|
readonly complete?: import("./iteration.ts").CompleteFn;
|
|
89
60
|
}
|
|
90
61
|
|
|
91
|
-
/** Optional payload for a fresh-session history reset at a phase boundary. */
|
|
92
|
-
export interface PhaseHistoryOptions {
|
|
93
|
-
readonly goal?: GoalCapture;
|
|
94
|
-
readonly validation?: ValidationGateData;
|
|
95
|
-
/** Engine notice folded into the first user message (no console I/O). */
|
|
96
|
-
readonly notice?: string;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Fresh-session policy: at each phase boundary the conversation is replaced;
|
|
101
|
-
* artifacts (paths) are the only channel. REPL variables persist — the transition
|
|
102
|
-
* message tells the model context survives in the sandbox, not in chat.
|
|
103
|
-
*/
|
|
104
|
-
export function resetHistoryForPhase(
|
|
105
|
-
system: string,
|
|
106
|
-
state: PhaseState,
|
|
107
|
-
options: PhaseHistoryOptions = {},
|
|
108
|
-
): ChatMsg[] {
|
|
109
|
-
const { goal, validation, notice } = options;
|
|
110
|
-
const parts: string[] = [
|
|
111
|
-
`You are entering the '${state.current}' phase.`,
|
|
112
|
-
];
|
|
113
|
-
if (notice) parts.push(notice);
|
|
114
|
-
if (goal) {
|
|
115
|
-
parts.push(`The user's verbatim brief: read ${goal.goalPath} from the REPL (open()).`);
|
|
116
|
-
parts.push(`Pre-run dirty baseline (exclude from delta judgment): ${goal.baselinePath}`);
|
|
117
|
-
}
|
|
118
|
-
for (const [p, ref] of Object.entries(state.artifacts)) {
|
|
119
|
-
if (ref === undefined) continue;
|
|
120
|
-
parts.push(
|
|
121
|
-
ref.status === "superseded"
|
|
122
|
-
? `Superseded artifact from '${p}' (rejected by validation): ${ref.path} — read it and the validation before re-planning; do not repeat its blockers.`
|
|
123
|
-
: `Artifact from '${p}': ${ref.path}`,
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
if (validation) {
|
|
127
|
-
parts.push(
|
|
128
|
-
`Previous validation found ${validation.blockersCount} blocker(s) — read the validation artifact and address every blocker in the revised plan.`,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
parts.push(phaseGuidance(state.current));
|
|
132
|
-
parts.push("Your REPL variables persist; the chat history was reset to keep your window small.");
|
|
133
|
-
return [
|
|
134
|
-
{ role: "system", content: system },
|
|
135
|
-
{ role: "user", content: parts.join("\n") },
|
|
136
|
-
];
|
|
137
|
-
}
|
|
138
|
-
|
|
139
62
|
/** Build a `runRlm` bound to the given deps. The returned function is reused for recursion. */
|
|
140
63
|
export function createEngine(deps: EngineDeps): RunRlm {
|
|
141
64
|
const { emitter } = deps;
|
|
142
65
|
const run: RunRlm = async (input: RlmInput): Promise<RlmResult> => {
|
|
143
|
-
const
|
|
144
|
-
const persist = input.depth === 0 && deps.runState !== undefined;
|
|
145
|
-
const runCwd = deps.runState?.cwd ?? process.cwd();
|
|
146
|
-
// Compute runId early for run-state correlation on resume.
|
|
147
|
-
const runId = persist
|
|
148
|
-
? (input.resume ? input.resume.header.runId : generateRunId())
|
|
149
|
-
: undefined;
|
|
150
|
-
// I4: session-scoped pickle trust — nonce prevents cross-session snapshot replay.
|
|
151
|
-
// On resume, sessionNonce is undefined → no snapshots, history-only replay.
|
|
152
|
-
const sessionNonce = persist && !input.resume ? randomUUID() : undefined;
|
|
66
|
+
const runCwd = process.cwd();
|
|
153
67
|
// For depth > 0, input.parentNodeId is the subcall ID created by the parent's rlm-query bridge.
|
|
154
68
|
// For depth 0, input.parentNodeId is undefined — engine uses root-level bridge methods.
|
|
155
69
|
const selfReportId = input.depth === 0 ? undefined : input.parentNodeId;
|
|
@@ -174,15 +88,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
174
88
|
const model = overrideModel ?? deps.model;
|
|
175
89
|
|
|
176
90
|
// Create LimitGuard BEFORE the bridge so sub-LLM usage feeds into it.
|
|
177
|
-
// Children inherit the parent's remaining
|
|
178
|
-
//
|
|
179
|
-
// 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).
|
|
180
93
|
const limits = new LimitGuard({
|
|
181
|
-
maxBudgetUsd: input.remainingBudgetUsd ?? deps.limits?.maxBudgetUsd,
|
|
182
94
|
maxTimeoutMs: input.remainingTimeoutMs ?? deps.limits?.maxTimeoutMs,
|
|
183
95
|
maxErrors: deps.limits?.maxErrors,
|
|
184
96
|
maxTokens: deps.limits?.maxTokens,
|
|
185
|
-
}
|
|
97
|
+
});
|
|
186
98
|
|
|
187
99
|
// One Invocation for the whole run: this engine owns exactly one sandbox at one depth,
|
|
188
100
|
// and its emitter and LimitGuard outlive every sub-call it services — including
|
|
@@ -192,7 +104,6 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
192
104
|
parentId: selfReportId,
|
|
193
105
|
depth: input.depth,
|
|
194
106
|
limits: {
|
|
195
|
-
remainingBudgetUsd: () => limits.remainingBudgetUsd(),
|
|
196
107
|
remainingTimeoutMs: () => limits.remainingTimeoutMs(),
|
|
197
108
|
addUsage: (u) => {
|
|
198
109
|
limits.addUsage(u);
|
|
@@ -209,13 +120,18 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
209
120
|
let detachedIdle: (() => void) | undefined;
|
|
210
121
|
const subcalls = createSubcallHandlers({
|
|
211
122
|
resolve: () => invocation,
|
|
212
|
-
gates: deps.gates
|
|
123
|
+
gates: deps.gates
|
|
124
|
+
?? createSubcallGates(deps.config.maxConcurrentSubcalls, deps.config.maxConcurrentChildren),
|
|
213
125
|
registry: deps.registry,
|
|
214
|
-
|
|
126
|
+
getLlmModel: () => deps.llmModel,
|
|
215
127
|
getModel: () => model,
|
|
216
128
|
getConfig: () => deps.config,
|
|
217
129
|
signal: deps.signal,
|
|
218
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,
|
|
219
135
|
trackDetached: async (task) => {
|
|
220
136
|
detachedInFlight += 1;
|
|
221
137
|
try {
|
|
@@ -236,66 +152,32 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
236
152
|
detachedIdle = undefined;
|
|
237
153
|
};
|
|
238
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
|
+
};
|
|
239
174
|
let best = "";
|
|
240
175
|
let lastAnswer = "";
|
|
241
176
|
let compactions = 0;
|
|
242
177
|
let completedTurns = 0;
|
|
243
|
-
/** Owns all pipeline state (phase, per-phase latest save, ask rounds, pending reset). */
|
|
244
|
-
let pipeline: PipelineController | undefined;
|
|
245
178
|
let nodeStatus: "done" | "error" = "done";
|
|
246
|
-
let persistOn = persist;
|
|
247
|
-
if (persist && deps.runState && !input.resume && runId) {
|
|
248
|
-
const json = typeof input.context !== "string";
|
|
249
|
-
const sidecarOk = await writeContextSidecar(deps.runState.cwd, deps.runState.dir, runId, input.context, json);
|
|
250
|
-
if (!sidecarOk) {
|
|
251
|
-
persistOn = false; // QC: skip header if sidecar failed — prevents orphan trail referencing non-existent context
|
|
252
|
-
} else {
|
|
253
|
-
const header: RunHeader = {
|
|
254
|
-
kind: "header", v: STATE_SCHEMA_VERSION, runId, ts: nowIso(),
|
|
255
|
-
rootPrompt: input.rootPrompt,
|
|
256
|
-
context: { type: contextTypeLabel(input.context), chars: contextLength(input.context), json },
|
|
257
|
-
models: { model: model.id, worker: deps.workerModel.id },
|
|
258
|
-
meta: { maxIterations: deps.config.maxIterations, maxDepth: deps.config.maxDepth, orchestrator: deps.config.orchestrator, pipeline: deps.config.pipeline },
|
|
259
|
-
};
|
|
260
|
-
persistOn = await appendRow(deps.runState.cwd, deps.runState.dir, runId, header);
|
|
261
|
-
}
|
|
262
|
-
await pruneRuns(deps.runState.cwd, deps.runState.dir, deps.config.runLog?.maxRuns ?? 50); // Ops: retention (always — cleanup even if sidecar failed)
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const recordTerminal = async (status: "completed" | "finalized" | "aborted" | "stopped", r: RlmResult): Promise<boolean> => {
|
|
266
|
-
if (!persistOn || !runId || !deps.runState) return false;
|
|
267
|
-
return await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
|
|
268
|
-
kind: "terminal", ts: nowIso(), status, answer: r.answer, iterations: r.iterations,
|
|
269
|
-
usage: { costUsd: r.costUsd, inputTokens: r.inputTokens, outputTokens: r.outputTokens },
|
|
270
|
-
});
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
const persistPhaseRow = async (
|
|
274
|
-
state: PhaseState,
|
|
275
|
-
artifactPath: string | undefined,
|
|
276
|
-
artifactPhase: Phase | undefined,
|
|
277
|
-
gateData: StageGateData | undefined,
|
|
278
|
-
supersededPath?: string,
|
|
279
|
-
): Promise<void> => {
|
|
280
|
-
if (!persistOn || !runId || !deps.runState) return;
|
|
281
|
-
const row: PhaseRow = {
|
|
282
|
-
kind: "phase",
|
|
283
|
-
turn: completedTurns + 1,
|
|
284
|
-
ts: nowIso(),
|
|
285
|
-
phase: state.current,
|
|
286
|
-
summary: state.summary,
|
|
287
|
-
artifactPath,
|
|
288
|
-
artifactPhase,
|
|
289
|
-
blockersCount: gateData?.kind === "validation" ? gateData.validation.blockersCount : undefined,
|
|
290
|
-
backwardJumps: state.backwardJumps,
|
|
291
|
-
supersededPath,
|
|
292
|
-
};
|
|
293
|
-
const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, row);
|
|
294
|
-
if (!ok) persistOn = false;
|
|
295
|
-
};
|
|
296
179
|
|
|
297
180
|
try {
|
|
298
|
-
const pipelineOn = input.depth === 0 && deps.config.pipeline;
|
|
299
181
|
const meta = {
|
|
300
182
|
contextType: contextTypeLabel(input.context),
|
|
301
183
|
contextChars: contextLength(input.context),
|
|
@@ -305,74 +187,23 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
305
187
|
const system = buildRlmSystemPrompt(meta, {
|
|
306
188
|
orchestrator: deps.config.orchestrator,
|
|
307
189
|
recursion: input.depth + 1 < deps.config.maxDepth,
|
|
308
|
-
askUserQuestion: deps.config.askUserQuestion && input.depth === 0,
|
|
309
|
-
todo: deps.config.todo,
|
|
310
|
-
pipeline: deps.config.pipeline && input.depth === 0,
|
|
311
190
|
maxPromptChars: deps.config.maxPromptChars,
|
|
312
191
|
libraryLoader: deps.config.libraryLoader,
|
|
192
|
+
child: input.depth > 0,
|
|
313
193
|
});
|
|
314
194
|
|
|
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() : {};
|
|
324
|
-
const baseAsk = deps.config.askUserQuestion ? deps.onAskUserQuestion : undefined;
|
|
325
|
-
const interactiveHandlers = buildInteractiveHandlers({
|
|
326
|
-
onAskUserQuestion: baseAsk
|
|
327
|
-
? async (questions) => {
|
|
328
|
-
const answers = await baseAsk(questions);
|
|
329
|
-
// Count only successfully serviced root-depth rounds (handler already rejects depth>0).
|
|
330
|
-
pipeline?.noteAskRound();
|
|
331
|
-
return answers;
|
|
332
|
-
}
|
|
333
|
-
: undefined,
|
|
334
|
-
onTodo: deps.config.todo ? deps.onTodo : undefined,
|
|
335
|
-
onTodoRow: async (action, params, todoResult) => {
|
|
336
|
-
if (!persistOn || !runId || !deps.runState) return;
|
|
337
|
-
const ok = await appendTodoRow(deps.runState.cwd, deps.runState.dir, runId, {
|
|
338
|
-
turn: completedTurns + 1, ts: nowIso(), action, params, result: todoResult,
|
|
339
|
-
});
|
|
340
|
-
if (!ok) persistOn = false;
|
|
341
|
-
},
|
|
342
|
-
emitter,
|
|
343
|
-
depth: input.depth,
|
|
344
|
-
parentId: selfReportId,
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
const restoredSlots = input.resume && deps.runState && runId
|
|
348
|
-
? await readLibrarySidecars(deps.runState.cwd, deps.runState.dir, runId)
|
|
349
|
-
: [];
|
|
350
|
-
// Seed host-side idempotency from restored sidecars so re-load is a no-op.
|
|
351
|
-
const restoredPrefixes: string[] = [];
|
|
352
|
-
for (const slot of restoredSlots) {
|
|
353
|
-
if (!Array.isArray(slot.payload) || slot.payload.length === 0) continue;
|
|
354
|
-
const first = slot.payload[0];
|
|
355
|
-
if (first === null || typeof first !== "object") continue;
|
|
356
|
-
const path = typeof (first as { path?: unknown }).path === "string"
|
|
357
|
-
? (first as { path: string }).path
|
|
358
|
-
: "";
|
|
359
|
-
const m = path.match(/^(lib\/[^/]+\/)/);
|
|
360
|
-
if (m?.[1] !== undefined) restoredPrefixes.push(m[1]);
|
|
361
|
-
}
|
|
362
195
|
const libraryHandlers = deps.config.libraryLoader
|
|
363
196
|
? buildLibraryHandler({
|
|
364
197
|
cwd: runCwd,
|
|
365
198
|
emitter,
|
|
366
199
|
parentId: selfReportId,
|
|
367
200
|
signal: deps.signal,
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
payload, typeof payload !== "string", index,
|
|
375
|
-
);
|
|
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();
|
|
376
207
|
},
|
|
377
208
|
}).handlers
|
|
378
209
|
: {};
|
|
@@ -386,85 +217,36 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
386
217
|
initTimeoutMs: deps.config.sandboxInitTimeoutMs,
|
|
387
218
|
maxPromptChars: deps.config.maxPromptChars,
|
|
388
219
|
awaitTimeoutS: Math.round(deps.config.requestTimeoutMs / 1000),
|
|
389
|
-
|
|
390
|
-
readOnly: pipelineOn,
|
|
391
|
-
handlers: { ...subcalls, ...phaseHandlers, ...interactiveHandlers, ...libraryHandlers },
|
|
220
|
+
handlers: { ...subcalls, ...libraryHandlers },
|
|
392
221
|
});
|
|
393
222
|
|
|
394
|
-
let history: ChatMsg[] =
|
|
395
|
-
let pendingReplOutputs: string | undefined
|
|
396
|
-
const startTurn = input.resume?.completedTurns ?? 0;
|
|
397
|
-
if (input.resume) {
|
|
398
|
-
limits.addRaw(input.resume.usageSeed.costUsd, input.resume.usageSeed.inputTokens, input.resume.usageSeed.outputTokens);
|
|
399
|
-
best = input.resume.best;
|
|
400
|
-
compactions = input.resume.compactions;
|
|
401
|
-
completedTurns = input.resume.completedTurns;
|
|
402
|
-
if (input.resume.phase) pipeline.seedFromResume(input.resume.phase);
|
|
403
|
-
} else if (pipelineOn) {
|
|
404
|
-
// Goal capture (script, no LLM) + seed phase state + fresh history.
|
|
405
|
-
const captured = captureGoal(runCwd, input.rootPrompt);
|
|
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.`;
|
|
410
|
-
// Clarify only when interviews are enabled AND the host wired a callback.
|
|
411
|
-
// Config alone is not enough: without onAskUserQuestion every ask throws and
|
|
412
|
-
// the run would burn maxIterations stuck at clarify (askRounds stays 0).
|
|
413
|
-
const startPhase =
|
|
414
|
-
deps.config.askUserQuestion && deps.onAskUserQuestion !== undefined
|
|
415
|
-
? "clarify"
|
|
416
|
-
: "research";
|
|
417
|
-
history = pipeline.seedFresh(startPhase, captured.ok ? captured.value : undefined, goalNotice);
|
|
418
|
-
}
|
|
223
|
+
let history: ChatMsg[] = [{ role: "system", content: system }];
|
|
224
|
+
let pendingReplOutputs: string | undefined;
|
|
419
225
|
|
|
420
226
|
// Context: serialize ContextBundle to sandbox-ready JSON array, pass raw strings through.
|
|
421
|
-
|
|
422
|
-
let contextValue: unknown =
|
|
227
|
+
liveContext =
|
|
423
228
|
typeof input.context === "object" && input.context !== null && "files" in input.context
|
|
424
229
|
? serializeForSandbox(input.context as ContextBundle)
|
|
425
230
|
: input.context;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
await sandbox.loadContext(contextValue);
|
|
430
|
-
if (input.resume?.snapshotTurn !== undefined && deps.runState && runId && sessionNonce) // R-C1: restore only for same-session (sessionNonce present)
|
|
431
|
-
await sandbox.restore(snapshotPath(deps.runState.cwd, deps.runState.dir, runId, input.resume.snapshotTurn), sessionNonce);
|
|
432
|
-
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++) {
|
|
433
234
|
limits.checkTimeout();
|
|
434
235
|
if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
|
|
435
236
|
else emitter.emitTurn(i + 1, deps.config.maxIterations);
|
|
436
237
|
|
|
437
|
-
// Apply deferred history reset from a prior advance_phase (fresh session policy).
|
|
438
|
-
const scheduledReset = pipeline.takePendingReset();
|
|
439
|
-
if (scheduledReset !== undefined) {
|
|
440
|
-
history = scheduledReset;
|
|
441
|
-
pendingReplOutputs = undefined;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
238
|
if (deps.config.compaction) {
|
|
445
239
|
const compactionDeps = {
|
|
446
240
|
// Summarisation is done by the cheap worker model; the threshold stays on the
|
|
447
241
|
// root model's context window (that is the window the history fills each turn).
|
|
448
|
-
model: deps.
|
|
242
|
+
model: deps.llmModel,
|
|
449
243
|
registry: deps.registry,
|
|
450
244
|
contextWindow: model.contextWindow,
|
|
451
245
|
thresholdPct: deps.config.compactionThresholdPct,
|
|
452
246
|
signal: deps.signal,
|
|
453
247
|
};
|
|
454
248
|
if (shouldCompact(history, compactionDeps)) {
|
|
455
|
-
|
|
456
|
-
let compactionUsage = { costUsd: 0, inputTokens: 0, outputTokens: 0 };
|
|
457
|
-
history = await compactHistory(history, compactionDeps, ++compactions, (u) => {
|
|
458
|
-
limits.addUsage(u);
|
|
459
|
-
compactionUsage = { costUsd: compactionUsage.costUsd + u.cost.total, inputTokens: compactionUsage.inputTokens + u.input, outputTokens: compactionUsage.outputTokens + u.output }; // CC: accumulate
|
|
460
|
-
});
|
|
461
|
-
if (persistOn && runId && deps.runState && history !== prevHistoryRef) {
|
|
462
|
-
const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
|
|
463
|
-
kind: "compaction", turn: i + 1, ts: nowIso(), history,
|
|
464
|
-
usage: compactionUsage,
|
|
465
|
-
});
|
|
466
|
-
if (!ok) persistOn = false; // QC: disable persistence on first failure (match turn-row pattern)
|
|
467
|
-
}
|
|
249
|
+
history = await compactHistory(history, compactionDeps, ++compactions, (u) => limits.addUsage(u));
|
|
468
250
|
}
|
|
469
251
|
}
|
|
470
252
|
|
|
@@ -473,14 +255,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
473
255
|
pendingReplOutputs = undefined;
|
|
474
256
|
}
|
|
475
257
|
|
|
476
|
-
|
|
477
|
-
const gateUserMsg = gateMsg ? `[${new Date().toISOString()}] ${gateMsg}` : undefined;
|
|
478
|
-
// Phase guidance lives only in resetHistoryForPhase (fresh session) — do not re-inject
|
|
479
|
-
// into every turn prompt (avoids duplication on turn 1 and dead post-transition flags).
|
|
480
|
-
appendUserMessage(
|
|
481
|
-
history,
|
|
482
|
-
buildTurnPrompt(i, deps.config.maxIterations, gateUserMsg),
|
|
483
|
-
);
|
|
258
|
+
appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations));
|
|
484
259
|
|
|
485
260
|
// rootSampling fields win; smartReasoning is the default reasoning when not overridden.
|
|
486
261
|
const rootSampling: Sampling = {
|
|
@@ -510,86 +285,29 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
510
285
|
completedTurns = i + 1;
|
|
511
286
|
const final = finalAnswerOf(turn.results);
|
|
512
287
|
if (final != null) {
|
|
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") {
|
|
518
|
-
history.push({ role: "assistant", content: turn.response });
|
|
519
|
-
pendingReplOutputs = outcome.error;
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
if (outcome.kind === "loop-back") {
|
|
523
|
-
history = outcome.history;
|
|
524
|
-
pendingReplOutputs = undefined;
|
|
525
|
-
continue;
|
|
526
|
-
}
|
|
527
|
-
if (outcome.kind === "halt") {
|
|
528
|
-
const halted = result(outcome.report, i + 1, limits);
|
|
529
|
-
await recordTerminal("completed", halted);
|
|
530
|
-
lastAnswer = halted.answer;
|
|
531
|
-
return halted;
|
|
532
|
-
}
|
|
533
|
-
// outcome.kind === "accept" — take the model's final answer
|
|
534
|
-
}
|
|
535
288
|
const done = result(final, i + 1, limits);
|
|
536
|
-
await recordTerminal("completed", done);
|
|
537
289
|
lastAnswer = done.answer;
|
|
538
290
|
return done;
|
|
539
291
|
}
|
|
540
292
|
|
|
541
293
|
limits.observe(turnHadError(turn.results));
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
// If advance_phase scheduled a history reset, apply it now (do not pollute fresh history).
|
|
545
|
-
const nextReset = pipeline.takePendingReset();
|
|
546
|
-
if (nextReset !== undefined) {
|
|
547
|
-
history = nextReset;
|
|
548
|
-
// Fanout/advance result is already embedded in the reset user message — do not
|
|
549
|
-
// also append raw REPL stdout as a next-turn user message.
|
|
550
|
-
pendingReplOutputs = undefined;
|
|
551
|
-
} else {
|
|
552
|
-
history.push({ role: "assistant", content: turn.response });
|
|
553
|
-
pendingReplOutputs = turnReplOutputs;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
if (persistOn && runId && deps.runState) {
|
|
557
|
-
const pklPath = snapshotPath(deps.runState.cwd, deps.runState.dir, runId, i + 1);
|
|
558
|
-
const snapOk = deps.runState.snapshot && sandbox && sessionNonce
|
|
559
|
-
? await sandbox.snapshot(pklPath, sessionNonce)
|
|
560
|
-
: false;
|
|
561
|
-
const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
|
|
562
|
-
kind: "turn", turn: i + 1, ts: nowIso(),
|
|
563
|
-
response: turn.response,
|
|
564
|
-
// Trail keeps the real REPL output even when history was reset (issue #9).
|
|
565
|
-
replOutputs: turnReplOutputs || undefined,
|
|
566
|
-
answerContent: answerContent || undefined,
|
|
567
|
-
error: turnHadError(turn.results),
|
|
568
|
-
usage: { costUsd: turn.usage.cost.total, inputTokens: turn.usage.input, outputTokens: turn.usage.output }, // B2: Usage has .input/.output, not .inputTokens/.outputTokens
|
|
569
|
-
cumulativeDurationMs: limits.usage().durationMs, // B3: required by TurnRow, seeds LimitGuard clock on resume (CA)
|
|
570
|
-
snapshotOk: snapOk,
|
|
571
|
-
});
|
|
572
|
-
if (!ok) persistOn = false;
|
|
573
|
-
// No finalizeSnapshot — snapshot is atomic (os.rename inside worker.py)
|
|
574
|
-
}
|
|
294
|
+
history.push({ role: "assistant", content: turn.response });
|
|
295
|
+
pendingReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
|
|
575
296
|
}
|
|
576
297
|
if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
|
|
577
298
|
const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
|
|
578
|
-
await recordTerminal("finalized", finalized);
|
|
579
299
|
lastAnswer = finalized.answer;
|
|
580
300
|
return finalized;
|
|
581
301
|
} catch (err) {
|
|
582
302
|
// Abort is a user action — resolve with the best partial, not an error.
|
|
583
303
|
if (deps.signal?.aborted) {
|
|
584
304
|
const aborted = result(best.trim() || "(aborted)", completedTurns, limits);
|
|
585
|
-
await recordTerminal("aborted", aborted);
|
|
586
305
|
lastAnswer = aborted.answer;
|
|
587
306
|
return aborted;
|
|
588
307
|
}
|
|
589
308
|
if (err instanceof LimitError) {
|
|
590
309
|
nodeStatus = "error";
|
|
591
310
|
const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits);
|
|
592
|
-
await recordTerminal("stopped", stopped);
|
|
593
311
|
lastAnswer = stopped.answer;
|
|
594
312
|
return stopped;
|
|
595
313
|
}
|
|
@@ -607,7 +325,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
607
325
|
if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
|
|
608
326
|
emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
|
|
609
327
|
}
|
|
328
|
+
// Settle detached work FIRST: a child still running may be about to pin this same payload.
|
|
610
329
|
await settleDetached();
|
|
330
|
+
await contextPin?.release();
|
|
611
331
|
await sandbox?.dispose();
|
|
612
332
|
}
|
|
613
333
|
};
|
package/src/core/history.ts
CHANGED
package/src/core/limits.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* LimitGuard — wall-clock, token,
|
|
2
|
+
* LimitGuard — wall-clock, token, and consecutive-error caps for a headless RLM run
|
|
3
3
|
* (ported from rlm/core/rlm.py `_check_timeout` / `_check_iteration_limits`). Any breach throws
|
|
4
4
|
* a LimitError; the engine catches it and returns the best partial answer it has.
|
|
5
|
+
*
|
|
6
|
+
* Cost is tracked for reporting only — there is no USD spend ceiling.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
@@ -9,14 +11,12 @@ import type { Usage } from "@earendil-works/pi-ai";
|
|
|
9
11
|
export interface Limits {
|
|
10
12
|
readonly maxTimeoutMs?: number;
|
|
11
13
|
readonly maxTokens?: number;
|
|
12
|
-
readonly maxBudgetUsd?: number;
|
|
13
14
|
readonly maxErrors?: number;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/** Pick the limit caps out of a config (`RlmConfig` satisfies this structurally). */
|
|
17
18
|
export function limitsFromConfig(config: Limits): Limits {
|
|
18
19
|
return {
|
|
19
|
-
maxBudgetUsd: config.maxBudgetUsd,
|
|
20
20
|
maxTimeoutMs: config.maxTimeoutMs,
|
|
21
21
|
maxTokens: config.maxTokens,
|
|
22
22
|
maxErrors: config.maxErrors,
|
|
@@ -33,7 +33,7 @@ export interface UsageSnapshot {
|
|
|
33
33
|
|
|
34
34
|
export class LimitError extends Error {
|
|
35
35
|
constructor(
|
|
36
|
-
public readonly kind: "timeout" | "tokens" | "
|
|
36
|
+
public readonly kind: "timeout" | "tokens" | "errors",
|
|
37
37
|
message: string,
|
|
38
38
|
) {
|
|
39
39
|
super(message);
|
|
@@ -77,16 +77,13 @@ export class LimitGuard {
|
|
|
77
77
|
/** Call after each turn with whether the turn's REPL produced an error. */
|
|
78
78
|
observe(hadError: boolean): void {
|
|
79
79
|
this.consecutiveErrors = hadError ? this.consecutiveErrors + 1 : 0;
|
|
80
|
-
const { maxErrors, maxTokens
|
|
80
|
+
const { maxErrors, maxTokens } = this.limits;
|
|
81
81
|
if (maxErrors && this.consecutiveErrors >= maxErrors) {
|
|
82
82
|
throw new LimitError("errors", `${this.consecutiveErrors} consecutive errors (limit ${maxErrors})`);
|
|
83
83
|
}
|
|
84
84
|
if (maxTokens && this.inputTokens + this.outputTokens > maxTokens) {
|
|
85
85
|
throw new LimitError("tokens", `${this.inputTokens + this.outputTokens} tokens (limit ${maxTokens})`);
|
|
86
86
|
}
|
|
87
|
-
if (maxBudgetUsd && this.costUsd > maxBudgetUsd) {
|
|
88
|
-
throw new LimitError("budget", `$${this.costUsd.toFixed(4)} spent (limit $${maxBudgetUsd})`);
|
|
89
|
-
}
|
|
90
87
|
}
|
|
91
88
|
|
|
92
89
|
usage(): UsageSnapshot {
|
|
@@ -98,10 +95,6 @@ export class LimitGuard {
|
|
|
98
95
|
};
|
|
99
96
|
}
|
|
100
97
|
|
|
101
|
-
remainingBudgetUsd(): number | undefined {
|
|
102
|
-
return this.limits.maxBudgetUsd === undefined ? undefined : this.limits.maxBudgetUsd - this.costUsd;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
98
|
remainingTimeoutMs(): number | undefined {
|
|
106
99
|
return this.limits.maxTimeoutMs === undefined ? undefined : this.limits.maxTimeoutMs - (Date.now() - this.start);
|
|
107
100
|
}
|
|
@@ -3,12 +3,10 @@
|
|
|
3
3
|
import { formatError } from "../util/errors.ts";
|
|
4
4
|
|
|
5
5
|
export interface RemainingResources {
|
|
6
|
-
readonly budgetUsd?: number;
|
|
7
6
|
readonly timeoutMs?: number;
|
|
8
7
|
}
|
|
9
8
|
|
|
10
9
|
export function checkResourceLimits(resources: RemainingResources): string | undefined {
|
|
11
|
-
if (resources.budgetUsd !== undefined && resources.budgetUsd <= 0) return formatError("budget exhausted");
|
|
12
10
|
if (resources.timeoutMs !== undefined && resources.timeoutMs <= 0) return formatError("timeout exhausted");
|
|
13
11
|
return undefined;
|
|
14
12
|
}
|