@hicaru/pi-rlm 0.1.0

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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +237 -0
  3. package/README.ru.md +200 -0
  4. package/README.zh-CN.md +224 -0
  5. package/package.json +54 -0
  6. package/src/bridge/fallback-todo.ts +137 -0
  7. package/src/bridge/interactive.ts +65 -0
  8. package/src/bridge/llm-query.ts +124 -0
  9. package/src/bridge/model.ts +97 -0
  10. package/src/bridge/pi-interactive.ts +86 -0
  11. package/src/bridge/rlm-query.ts +78 -0
  12. package/src/commands/rlm-config.ts +42 -0
  13. package/src/commands/rlm.ts +165 -0
  14. package/src/config/defaults.ts +38 -0
  15. package/src/config/settings.ts +185 -0
  16. package/src/context/repomix-context.ts +253 -0
  17. package/src/core/answer.ts +97 -0
  18. package/src/core/compaction.ts +64 -0
  19. package/src/core/engine.ts +408 -0
  20. package/src/core/history.ts +13 -0
  21. package/src/core/iteration.ts +45 -0
  22. package/src/core/limits.ts +90 -0
  23. package/src/core/pipeline.ts +100 -0
  24. package/src/core/resource-limits.ts +14 -0
  25. package/src/core/types.ts +131 -0
  26. package/src/index.ts +165 -0
  27. package/src/mode/input-router.ts +23 -0
  28. package/src/mode/rlm-mode.ts +149 -0
  29. package/src/patch/apply.ts +148 -0
  30. package/src/patch/index.ts +37 -0
  31. package/src/prompts/system.ts +278 -0
  32. package/src/prompts/user.ts +21 -0
  33. package/src/sandbox/protocol.ts +191 -0
  34. package/src/sandbox/sandbox-manager.ts +143 -0
  35. package/src/sandbox/sandbox.ts +362 -0
  36. package/src/sandbox/worker.py +457 -0
  37. package/src/state/events.ts +22 -0
  38. package/src/state/index.ts +23 -0
  39. package/src/state/internal.ts +46 -0
  40. package/src/state/paths.ts +42 -0
  41. package/src/state/reads.ts +96 -0
  42. package/src/state/resume.ts +154 -0
  43. package/src/state/rows.ts +117 -0
  44. package/src/state/writes.ts +56 -0
  45. package/src/telemetry/dispatcher.ts +116 -0
  46. package/src/telemetry/index.ts +14 -0
  47. package/src/telemetry/mlflow-config.ts +15 -0
  48. package/src/telemetry/mlflow-sink.ts +136 -0
  49. package/src/telemetry/mlflow.ts +99 -0
  50. package/src/telemetry/sink.ts +8 -0
  51. package/src/text/edits.ts +16 -0
  52. package/src/text/parsing.ts +35 -0
  53. package/src/text/preview.ts +18 -0
  54. package/src/text/tokens.ts +64 -0
  55. package/src/tool/apply-diff-tool.ts +125 -0
  56. package/src/tool/emitter-listener.ts +24 -0
  57. package/src/tool/repl-details.ts +23 -0
  58. package/src/tool/repl-tool.ts +528 -0
  59. package/src/tool/rlm-aggregator.ts +115 -0
  60. package/src/tool/rlm-details.ts +53 -0
  61. package/src/tool/rlm-events.ts +215 -0
  62. package/src/tool/rlm-tool.ts +199 -0
  63. package/src/tool/subcall-render.ts +129 -0
  64. package/src/tool/subcall-store.ts +90 -0
  65. package/src/tool/tool-utils.ts +73 -0
  66. package/src/ui/config-panel.ts +92 -0
  67. package/src/ui/intro.ts +23 -0
  68. package/src/ui/model-picker.ts +139 -0
  69. package/src/ui/status.ts +26 -0
  70. package/src/ui/theme.ts +47 -0
  71. package/src/util/concurrency.ts +15 -0
  72. package/src/util/errors.ts +27 -0
@@ -0,0 +1,408 @@
1
+ /**
2
+ * runRlm — the headless RLM loop (port of rlm/core/rlm.py `completion()`).
3
+ *
4
+ * Each call owns a fresh sandbox, drives the root model turn-by-turn over ```repl``` blocks,
5
+ * services `llm_query`/`rlm_query` via the bridges, and stops when the model submits an answer
6
+ * or a limit/turn cap is hit. Recursion is wired by giving the sandbox rlm handlers that call
7
+ * back into `runRlm` at depth+1. Used for recursion and for headless/automation runs.
8
+ */
9
+
10
+ import type { Api, Model, Usage } from "@earendil-works/pi-ai";
11
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
12
+ import { buildInteractiveHandlers } from "../bridge/interactive.ts";
13
+ import { createLlmBridge } from "../bridge/llm-query.ts";
14
+ import { type ChatMsg, modelComplete } from "../bridge/model.ts";
15
+ import { createRlmHandlers } from "../bridge/rlm-query.ts";
16
+ import { resolveModelId } from "../config/settings.ts";
17
+ import { buildRlmSystemPrompt } from "../prompts/system.ts";
18
+ import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
19
+ import type { RlmEmitter } from "../tool/rlm-events.ts";
20
+ import { PythonSandbox } from "../sandbox/sandbox.ts";
21
+ import { advancePhase as validatePhaseTransition, phaseGatePrompt, type PhaseState } from "./pipeline.ts";
22
+ import { previewStdout, previewText } from "../text/preview.ts";
23
+ import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
24
+ import { collectDiffs, collectEdits, finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
25
+ import { compactHistory, shouldCompact } from "./compaction.ts";
26
+ import { appendUserMessage } from "./history.ts";
27
+ import { runTurn } from "./iteration.ts";
28
+ import { type Limits, LimitError, LimitGuard } from "./limits.ts";
29
+ import type { InteractiveDeps, RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
30
+ import { randomUUID } from "node:crypto";
31
+ import { appendRow, appendTodoRow, generateRunId, pruneRuns, snapshotPath, writeContextSidecar } from "../state/index.ts";
32
+ import { STATE_SCHEMA_VERSION } from "../state/rows.ts";
33
+ import type { PhaseRow, RunHeader } from "../state/rows.ts";
34
+ import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
35
+ import type { ProposedDiffEdit, ProposedEdit } from "../sandbox/protocol.ts";
36
+ import { formatError } from "../util/errors.ts";
37
+
38
+
39
+ export interface EngineDeps extends InteractiveDeps {
40
+ readonly model: Model<Api>;
41
+ readonly workerModel: Model<Api>;
42
+ readonly registry: ModelRegistry;
43
+ readonly config: RlmConfig;
44
+ readonly limits?: Limits;
45
+ readonly signal?: AbortSignal;
46
+ /** Live RlmDetails reporting via onUpdate. Required — replaces SubcallObserver. */
47
+ readonly emitter: RlmEmitter;
48
+ /** Called with each completion's usage (root + sub-LLM) for cost/token rollups. */
49
+ readonly onUsage?: (usage: Usage, role: "root" | "sub") => void;
50
+ /** Run-state persistence handle. undefined ⇒ persistence off. */
51
+ readonly runState?: { readonly cwd: string; readonly dir: string; readonly snapshot: boolean };
52
+ }
53
+
54
+ /** Build a `runRlm` bound to the given deps. The returned function is reused for recursion. */
55
+ export function createEngine(deps: EngineDeps): RunRlm {
56
+ const { emitter } = deps;
57
+ const run: RunRlm = async (input: RlmInput): Promise<RlmResult> => {
58
+ const nowIso = (): string => new Date().toISOString(); // local helper — 4 call sites below
59
+ const persist = input.depth === 0 && deps.runState !== undefined;
60
+ // Compute runId early so it can tag the MLflow root span (Ops: trace correlation on resume).
61
+ const runId = persist
62
+ ? (input.resume ? input.resume.header.runId : generateRunId())
63
+ : undefined;
64
+ // I4: session-scoped pickle trust — nonce prevents cross-session snapshot replay.
65
+ // On resume, sessionNonce is undefined → no snapshots, history-only replay.
66
+ const sessionNonce = persist && !input.resume ? randomUUID() : undefined;
67
+ // For depth > 0, input.parentNodeId is the subcall ID created by the parent's rlm-query bridge.
68
+ // For depth 0, input.parentNodeId is undefined — engine uses root-level bridge methods.
69
+ const selfReportId = input.depth === 0 ? undefined : input.parentNodeId;
70
+ if (!selfReportId) {
71
+ emitter.emitRootPrompt(input.rootPrompt ? input.rootPrompt.slice(0, 60) : String(input.context).slice(0, 60));
72
+ emitter.emitTurn(0, deps.config.maxIterations);
73
+ }
74
+
75
+ const overrideModel = input.modelOverride ? resolveModelId(deps.registry, input.modelOverride) : undefined;
76
+ if (input.modelOverride && !overrideModel) {
77
+ if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, status: "error", detail: "unknown model override" });
78
+ else emitter.emitStatus("error");
79
+ return {
80
+ answer: formatError(`unknown model override '${input.modelOverride}'`),
81
+ edits: [],
82
+ iterations: 0,
83
+ costUsd: 0,
84
+ inputTokens: 0,
85
+ outputTokens: 0,
86
+ durationMs: 0,
87
+ };
88
+ }
89
+ const model = overrideModel ?? deps.model;
90
+
91
+ // Create LimitGuard BEFORE the bridge so sub-LLM usage feeds into it.
92
+ // Children inherit the parent's remaining budget/timeout (reference: limits propagate
93
+ // as remaining amounts, not the full original cap).
94
+ // CA: seed the clock on resume so resumed runs don't get a fresh timeout budget.
95
+ const limits = new LimitGuard({
96
+ maxBudgetUsd: input.remainingBudgetUsd ?? deps.limits?.maxBudgetUsd,
97
+ maxTimeoutMs: input.remainingTimeoutMs ?? deps.limits?.maxTimeoutMs,
98
+ maxErrors: deps.limits?.maxErrors,
99
+ maxTokens: deps.limits?.maxTokens,
100
+ }, input.resume?.usageSeed.durationMs ?? 0);
101
+
102
+ const llm = createLlmBridge({
103
+ workerModel: deps.workerModel,
104
+ registry: deps.registry,
105
+ subSystem: deps.config.subSystemPrompt,
106
+ maxPromptChars: deps.config.maxPromptChars,
107
+ maxConcurrent: deps.config.maxConcurrentSubcalls,
108
+ sampling: deps.config.subSampling,
109
+ signal: deps.signal,
110
+ onUsage: (u) => {
111
+ limits.addUsage(u);
112
+ deps.onUsage?.(u, "sub");
113
+ },
114
+ emitter,
115
+ parentId: selfReportId,
116
+ depth: input.depth,
117
+ });
118
+ const rlm = createRlmHandlers({
119
+ run,
120
+ llm,
121
+ emitter,
122
+ maxDepth: deps.config.maxDepth,
123
+ maxConcurrent: deps.config.maxConcurrentSubcalls,
124
+ parentNodeId: selfReportId,
125
+ remainingBudget: () => ({
126
+ budgetUsd: limits.remainingBudgetUsd(),
127
+ timeoutMs: limits.remainingTimeoutMs(),
128
+ }),
129
+ onChildUsage: (costUsd, inputTokens, outputTokens) => {
130
+ limits.addRaw(costUsd, inputTokens, outputTokens);
131
+ },
132
+ });
133
+ let sandbox: PythonSandbox | undefined;
134
+ let best = "";
135
+ let lastAnswer = "";
136
+ let compactions = 0;
137
+ let completedTurns = 0;
138
+ let editsAcc: ProposedEdit[] = [];
139
+ let diffsAcc: ProposedDiffEdit[] = [];
140
+ let phaseState: PhaseState | undefined;
141
+ let nodeStatus: "done" | "error" = "done";
142
+ let persistOn = persist;
143
+ if (persist && deps.runState && !input.resume && runId) {
144
+ const json = typeof input.context !== "string";
145
+ const sidecarOk = await writeContextSidecar(deps.runState.cwd, deps.runState.dir, runId, input.context, json);
146
+ if (!sidecarOk) {
147
+ persistOn = false; // QC: skip header if sidecar failed — prevents orphan trail referencing non-existent context
148
+ } else {
149
+ const header: RunHeader = {
150
+ kind: "header", v: STATE_SCHEMA_VERSION, runId, ts: nowIso(),
151
+ rootPrompt: input.rootPrompt,
152
+ context: { type: contextTypeLabel(input.context), chars: contextLength(input.context), json },
153
+ models: { model: model.id, worker: deps.workerModel.id },
154
+ meta: { maxIterations: deps.config.maxIterations, maxDepth: deps.config.maxDepth, orchestrator: deps.config.orchestrator, pipeline: true },
155
+ };
156
+ persistOn = await appendRow(deps.runState.cwd, deps.runState.dir, runId, header);
157
+ }
158
+ await pruneRuns(deps.runState.cwd, deps.runState.dir, deps.config.runLog?.maxRuns ?? 50); // Ops: retention (always — cleanup even if sidecar failed)
159
+ }
160
+
161
+ const recordTerminal = async (status: "completed" | "finalized" | "aborted" | "stopped", r: RlmResult): Promise<boolean> => {
162
+ if (!persistOn || !runId || !deps.runState) return false;
163
+ return await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
164
+ kind: "terminal", ts: nowIso(), status, answer: r.answer, iterations: r.iterations,
165
+ usage: { costUsd: r.costUsd, inputTokens: r.inputTokens, outputTokens: r.outputTokens },
166
+ });
167
+ };
168
+
169
+ try {
170
+ const phaseHandlers = input.depth === 0
171
+ ? {
172
+ advancePhase: async (phase: string, summary: string | undefined) => {
173
+ const outcome = validatePhaseTransition(phaseState?.current, phase);
174
+ if (!outcome.ok) return formatError(outcome.error);
175
+ const previous = phaseState;
176
+ phaseState = { current: outcome.phase, advancedAt: completedTurns, summary };
177
+ if (persistOn && runId && deps.runState) {
178
+ const row: PhaseRow = { kind: "phase", turn: completedTurns + 1, ts: nowIso(), phase: outcome.phase, summary };
179
+ const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, row);
180
+ if (!ok) persistOn = false;
181
+ }
182
+ const prevLabel = previous ? `was '${previous.current}'` : "fresh run";
183
+ return `ok — phase advanced to '${outcome.phase}' (${prevLabel}${summary ? `, summary: ${summary.slice(0, 80)}` : ""})`;
184
+ },
185
+ }
186
+ : {};
187
+ const interactiveHandlers = buildInteractiveHandlers({
188
+ onAskUserQuestion: deps.config.askUserQuestion ? deps.onAskUserQuestion : undefined,
189
+ onTodo: deps.config.todo ? deps.onTodo : undefined,
190
+ onTodoRow: async (action, params, todoResult) => {
191
+ if (!persistOn || !runId || !deps.runState) return;
192
+ const ok = await appendTodoRow(deps.runState.cwd, deps.runState.dir, runId, {
193
+ turn: completedTurns + 1, ts: nowIso(), action, params, result: todoResult,
194
+ });
195
+ if (!ok) persistOn = false;
196
+ },
197
+ emitter,
198
+ depth: input.depth,
199
+ parentId: selfReportId,
200
+ });
201
+
202
+ sandbox = await PythonSandbox.spawn({
203
+ depth: input.depth,
204
+ execTimeoutS: deps.config.execTimeoutS,
205
+ requestTimeoutMs: deps.config.requestTimeoutMs,
206
+ python: deps.config.python,
207
+ signal: deps.signal,
208
+ initTimeoutMs: deps.config.sandboxInitTimeoutMs,
209
+ handlers: { ...llm, ...rlm, ...phaseHandlers, ...interactiveHandlers },
210
+ });
211
+
212
+ const meta = {
213
+ contextType: contextTypeLabel(input.context),
214
+ contextChars: contextLength(input.context),
215
+ contextStats: contextSizeStats(input.context),
216
+ rootPrompt: input.rootPrompt || undefined,
217
+ };
218
+ const system = buildRlmSystemPrompt(meta, {
219
+ orchestrator: deps.config.orchestrator,
220
+ recursion: input.depth + 1 < deps.config.maxDepth,
221
+ askUserQuestion: deps.config.askUserQuestion && input.depth === 0,
222
+ todo: deps.config.todo,
223
+ });
224
+ let history: ChatMsg[] = input.resume ? input.resume.history : [{ role: "system", content: system }];
225
+ let pendingReplOutputs: string | undefined = input.resume?.pendingReplOutputs;
226
+ const startTurn = input.resume?.completedTurns ?? 0;
227
+ if (input.resume) {
228
+ limits.addRaw(input.resume.usageSeed.costUsd, input.resume.usageSeed.inputTokens, input.resume.usageSeed.outputTokens);
229
+ best = input.resume.best;
230
+ editsAcc = [];
231
+ diffsAcc = [];
232
+ compactions = input.resume.compactions;
233
+ completedTurns = input.resume.completedTurns;
234
+ if (input.resume.phase) {
235
+ const resumePhase = input.resume.phase;
236
+ phaseState = { current: resumePhase.current as PhaseState["current"], advancedAt: resumePhase.advancedAt, summary: resumePhase.summary };
237
+ }
238
+ }
239
+
240
+ // Context: serialize ContextBundle to sandbox-ready JSON array, pass raw strings through.
241
+ const contextValue = typeof input.context === "object" && input.context !== null && "files" in input.context
242
+ ? serializeForSandbox(input.context as ContextBundle)
243
+ : input.context;
244
+ await sandbox.loadContext(contextValue);
245
+ if (input.resume?.snapshotTurn !== undefined && deps.runState && runId && sessionNonce) // R-C1: restore only for same-session (sessionNonce present)
246
+ await sandbox.restore(snapshotPath(deps.runState.cwd, deps.runState.dir, runId, input.resume.snapshotTurn), sessionNonce);
247
+ for (let i = startTurn; i < deps.config.maxIterations; i++) {
248
+ limits.checkTimeout();
249
+ if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
250
+ else emitter.emitTurn(i + 1, deps.config.maxIterations);
251
+
252
+ if (pendingReplOutputs) {
253
+ appendUserMessage(history, pendingReplOutputs);
254
+ pendingReplOutputs = undefined;
255
+ }
256
+
257
+ if (deps.config.compaction) {
258
+ const compactionDeps = {
259
+ // Summarisation is done by the cheap worker model; the threshold stays on the
260
+ // root model's context window (that is the window the history fills each turn).
261
+ model: deps.workerModel,
262
+ registry: deps.registry,
263
+ contextWindow: model.contextWindow,
264
+ thresholdPct: deps.config.compactionThresholdPct,
265
+ signal: deps.signal,
266
+ };
267
+ if (shouldCompact(history, compactionDeps)) {
268
+ const prevHistoryRef = history;
269
+ let compactionUsage = { costUsd: 0, inputTokens: 0, outputTokens: 0 };
270
+ history = await compactHistory(history, compactionDeps, ++compactions, (u) => {
271
+ limits.addUsage(u);
272
+ compactionUsage = { costUsd: compactionUsage.costUsd + u.cost.total, inputTokens: compactionUsage.inputTokens + u.input, outputTokens: compactionUsage.outputTokens + u.output }; // CC: accumulate
273
+ });
274
+ if (persistOn && runId && deps.runState && history !== prevHistoryRef) {
275
+ const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
276
+ kind: "compaction", turn: i + 1, ts: nowIso(), history,
277
+ usage: compactionUsage,
278
+ });
279
+ if (!ok) persistOn = false; // QC: disable persistence on first failure (match turn-row pattern)
280
+ }
281
+ }
282
+ }
283
+
284
+ const gateMsg = phaseGatePrompt(phaseState, completedTurns);
285
+ const gateUserMsg = gateMsg ? `[${new Date().toISOString()}] ${gateMsg}` : undefined;
286
+ appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, gateUserMsg));
287
+
288
+ // rootSampling fields win; smartReasoning is the default reasoning when not overridden.
289
+ const rootSampling: Sampling = {
290
+ reasoning: deps.config.smartReasoning,
291
+ ...deps.config.rootSampling,
292
+ };
293
+ const turn = await runTurn(history, sandbox, {
294
+ model: model,
295
+ registry: deps.registry,
296
+ sampling: rootSampling,
297
+ signal: deps.signal,
298
+ });
299
+ const allBlocks = turn.blocks.length > 0
300
+ ? turn.blocks.map((b) => previewText(b, 400)).join("\n")
301
+ : previewText(turn.response, 400);
302
+ if (selfReportId) {
303
+ emitter.emitSubcallUpdated({ id: selfReportId, args: `▶ ${allBlocks}`, resultPreview: previewStdout(turn.results) });
304
+ }
305
+ limits.addUsage(turn.usage);
306
+ if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, costUsd: turn.usage.cost.total, tokens: turn.usage.totalTokens });
307
+ else emitter.emitRootUsage(turn.usage.cost.total, turn.usage.totalTokens);
308
+ deps.onUsage?.(turn.usage, "root");
309
+ const answerContent = latestAnswerContentOf(turn.results);
310
+ if (answerContent) best = answerContent;
311
+ else if (!best && turn.response.trim()) best = turn.response;
312
+ completedTurns = i + 1;
313
+ const proposedEdits = collectEdits(turn.results);
314
+ if (proposedEdits.length > 0) editsAcc = proposedEdits;
315
+ const proposedDiffs = collectDiffs(turn.results);
316
+ if (proposedDiffs.length > 0) diffsAcc = proposedDiffs;
317
+
318
+ const final = finalAnswerOf(turn.results);
319
+ if (final != null) {
320
+ const done = result(final, i + 1, limits, editsAcc, diffsAcc);
321
+ await recordTerminal("completed", done);
322
+ lastAnswer = done.answer;
323
+ return done;
324
+ }
325
+
326
+ limits.observe(turnHadError(turn.results));
327
+ history.push({ role: "assistant", content: turn.response });
328
+ const turnReplOutputs = formatReplOutputs(turn.results);
329
+ pendingReplOutputs = turnReplOutputs;
330
+
331
+ if (persistOn && runId && deps.runState) {
332
+ const pklPath = snapshotPath(deps.runState.cwd, deps.runState.dir, runId, i + 1);
333
+ const snapOk = deps.runState.snapshot && sandbox && sessionNonce
334
+ ? await sandbox.snapshot(pklPath, sessionNonce)
335
+ : false;
336
+ const ok = await appendRow(deps.runState.cwd, deps.runState.dir, runId, {
337
+ kind: "turn", turn: i + 1, ts: nowIso(),
338
+ response: turn.response, replOutputs: turnReplOutputs || undefined,
339
+ answerContent: answerContent || undefined,
340
+ edits: proposedEdits.length > 0 ? proposedEdits : undefined,
341
+ error: turnHadError(turn.results),
342
+ usage: { costUsd: turn.usage.cost.total, inputTokens: turn.usage.input, outputTokens: turn.usage.output }, // B2: Usage has .input/.output, not .inputTokens/.outputTokens
343
+ cumulativeDurationMs: limits.usage().durationMs, // B3: required by TurnRow, seeds LimitGuard clock on resume (CA)
344
+ snapshotOk: snapOk,
345
+ });
346
+ if (!ok) persistOn = false;
347
+ // No finalizeSnapshot — snapshot is atomic (os.rename inside worker.py)
348
+ }
349
+ }
350
+ if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
351
+ const finalized = result(await finalize(history, deps, limits), deps.config.maxIterations, limits, editsAcc, diffsAcc);
352
+ await recordTerminal("finalized", finalized);
353
+ lastAnswer = finalized.answer;
354
+ return finalized;
355
+ } catch (err) {
356
+ // Abort is a user action — resolve with the best partial, not an error.
357
+ if (deps.signal?.aborted) {
358
+ const aborted = result(best.trim() || "(aborted)", completedTurns, limits, editsAcc, diffsAcc);
359
+ await recordTerminal("aborted", aborted);
360
+ lastAnswer = aborted.answer;
361
+ return aborted;
362
+ }
363
+ if (err instanceof LimitError) {
364
+ nodeStatus = "error";
365
+ const stopped = result(best.trim() || `(stopped: ${err.message})`, completedTurns, limits, editsAcc, diffsAcc);
366
+ await recordTerminal("stopped", stopped);
367
+ lastAnswer = stopped.answer;
368
+ return stopped;
369
+ }
370
+ nodeStatus = "error";
371
+ throw err;
372
+ } finally {
373
+ if (selfReportId) {
374
+ emitter.emitSubcallUpdated({
375
+ id: selfReportId,
376
+ status: nodeStatus,
377
+ resultPreview: nodeStatus === "error" ? undefined : previewText(lastAnswer),
378
+ detail: nodeStatus === "error" ? "stopped" : undefined,
379
+ });
380
+ } else {
381
+ if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
382
+ emitter.emitEdits(editsAcc.length > 0 ? editsAcc : []);
383
+ emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
384
+ }
385
+ await sandbox?.dispose();
386
+ }
387
+ };
388
+ return run;
389
+ }
390
+
391
+ function result(answer: string, iterations: number, limits: LimitGuard, edits: ProposedEdit[] = [], diffs: ProposedDiffEdit[] = []): RlmResult {
392
+ const u = limits.usage();
393
+ return { answer, edits, diffs, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
394
+ }
395
+
396
+ /** Out of turns: ask the model for its best final answer (plain text). */
397
+ async function finalize(history: ChatMsg[], deps: EngineDeps, limits: LimitGuard): Promise<string> {
398
+ const finalHistory = [...history];
399
+ appendUserMessage(finalHistory, FINALIZE_PROMPT);
400
+ const { text, usage } = await modelComplete(finalHistory, {
401
+ model: deps.model,
402
+ registry: deps.registry,
403
+ reasoning: deps.config.smartReasoning,
404
+ signal: deps.signal,
405
+ });
406
+ limits.addUsage(usage);
407
+ return text.trim();
408
+ }
@@ -0,0 +1,13 @@
1
+ /** Shared history mutation helpers used by both the engine loop and the resume fold. */
2
+
3
+ import type { ChatMsg } from "../bridge/model.ts";
4
+
5
+ /** Append content to the last user message if adjacent, otherwise push a new user message. */
6
+ export function appendUserMessage(history: ChatMsg[], content: string): void {
7
+ const last = history.at(-1);
8
+ if (last?.role === "user") {
9
+ history[history.length - 1] = { role: "user", content: [last.content, content].join("\n\n") };
10
+ return;
11
+ }
12
+ history.push({ role: "user", content });
13
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A single RLM turn for the headless engine: ask the root model, parse its ```repl``` blocks,
3
+ * execute each in the sandbox, and return the results. (The engine drives this; pi's loop is
4
+ * not involved.)
5
+ */
6
+
7
+ import type { Api, Model, Usage } from "@earendil-works/pi-ai";
8
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
9
+ import { type ChatMsg, modelComplete } from "../bridge/model.ts";
10
+ import type { ReplResult } from "../sandbox/protocol.ts";
11
+ import type { PythonSandbox } from "../sandbox/sandbox.ts";
12
+ import { findReplBlocks } from "../text/parsing.ts";
13
+ import type { Sampling } from "./types.ts";
14
+
15
+ export interface Turn {
16
+ readonly response: string;
17
+ readonly results: readonly ReplResult[];
18
+ readonly usage: Usage;
19
+ readonly blocks: readonly string[];
20
+ }
21
+
22
+ export interface TurnDeps {
23
+ readonly model: Model<Api>;
24
+ readonly registry: ModelRegistry;
25
+ readonly sampling?: Sampling;
26
+ readonly signal?: AbortSignal;
27
+ }
28
+
29
+ export async function runTurn(history: readonly ChatMsg[], sandbox: PythonSandbox, deps: TurnDeps): Promise<Turn> {
30
+ const { text, usage } = await modelComplete(history, {
31
+ model: deps.model,
32
+ registry: deps.registry,
33
+ maxTokens: deps.sampling?.maxTokens,
34
+ temperature: deps.sampling?.temperature,
35
+ reasoning: deps.sampling?.reasoning,
36
+ signal: deps.signal,
37
+ });
38
+
39
+ const blocks = findReplBlocks(text);
40
+ const results = new Array<ReplResult>(blocks.length);
41
+ for (let i = 0; i < blocks.length; i++) {
42
+ results[i] = await sandbox.exec(blocks[i]);
43
+ }
44
+ return { response: text, results, usage, blocks };
45
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * LimitGuard — wall-clock, token, cost, and consecutive-error caps for a headless RLM run
3
+ * (ported from rlm/core/rlm.py `_check_timeout` / `_check_iteration_limits`). Any breach throws
4
+ * a LimitError; the engine catches it and returns the best partial answer it has.
5
+ */
6
+
7
+ import type { Usage } from "@earendil-works/pi-ai";
8
+
9
+ export interface Limits {
10
+ readonly maxTimeoutMs?: number;
11
+ readonly maxTokens?: number;
12
+ readonly maxBudgetUsd?: number;
13
+ readonly maxErrors?: number;
14
+ }
15
+
16
+ export class LimitError extends Error {
17
+ constructor(
18
+ public readonly kind: "timeout" | "tokens" | "budget" | "errors",
19
+ message: string,
20
+ ) {
21
+ super(message);
22
+ this.name = "LimitError";
23
+ }
24
+ }
25
+
26
+ export class LimitGuard {
27
+ private start: number;
28
+ private inputTokens = 0;
29
+ private outputTokens = 0;
30
+ private costUsd = 0;
31
+ private consecutiveErrors = 0;
32
+
33
+ constructor(private readonly limits: Limits = {}, seedElapsedMs = 0) {
34
+ this.start = Date.now() - Math.max(0, seedElapsedMs); // C2: seed clock, clamp to prevent negative seed extending timeout budget
35
+ }
36
+
37
+ /** Call before each turn. */
38
+ checkTimeout(): void {
39
+ const { maxTimeoutMs } = this.limits;
40
+ if (maxTimeoutMs && Date.now() - this.start > maxTimeoutMs) {
41
+ throw new LimitError("timeout", `exceeded ${maxTimeoutMs}ms wall-clock limit`);
42
+ }
43
+ }
44
+
45
+ /** Fold a completion's usage into the running totals. */
46
+ addUsage(usage: Usage): void {
47
+ this.inputTokens += usage.input;
48
+ this.outputTokens += usage.output;
49
+ this.costUsd += usage.cost.total;
50
+ }
51
+
52
+ /** Fold a recursive child run's total cost/tokens into this guard. */
53
+ addRaw(costUsd: number, inputTokens: number, outputTokens: number): void {
54
+ this.costUsd += costUsd;
55
+ this.inputTokens += inputTokens;
56
+ this.outputTokens += outputTokens;
57
+ }
58
+
59
+ /** Call after each turn with whether the turn's REPL produced an error. */
60
+ observe(hadError: boolean): void {
61
+ this.consecutiveErrors = hadError ? this.consecutiveErrors + 1 : 0;
62
+ const { maxErrors, maxTokens, maxBudgetUsd } = this.limits;
63
+ if (maxErrors && this.consecutiveErrors >= maxErrors) {
64
+ throw new LimitError("errors", `${this.consecutiveErrors} consecutive errors (limit ${maxErrors})`);
65
+ }
66
+ if (maxTokens && this.inputTokens + this.outputTokens > maxTokens) {
67
+ throw new LimitError("tokens", `${this.inputTokens + this.outputTokens} tokens (limit ${maxTokens})`);
68
+ }
69
+ if (maxBudgetUsd && this.costUsd > maxBudgetUsd) {
70
+ throw new LimitError("budget", `$${this.costUsd.toFixed(4)} spent (limit $${maxBudgetUsd})`);
71
+ }
72
+ }
73
+
74
+ usage() {
75
+ return {
76
+ inputTokens: this.inputTokens,
77
+ outputTokens: this.outputTokens,
78
+ costUsd: this.costUsd,
79
+ durationMs: Date.now() - this.start,
80
+ };
81
+ }
82
+
83
+ remainingBudgetUsd(): number | undefined {
84
+ return this.limits.maxBudgetUsd === undefined ? undefined : this.limits.maxBudgetUsd - this.costUsd;
85
+ }
86
+
87
+ remainingTimeoutMs(): number | undefined {
88
+ return this.limits.maxTimeoutMs === undefined ? undefined : this.limits.maxTimeoutMs - (Date.now() - this.start);
89
+ }
90
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * RLM pipeline phase state machine.
3
+ *
4
+ * Hard-gated phases: research → blueprint → implement → validate.
5
+ * The root RLM calls advance_phase() to move forward; the engine enforces
6
+ * forward-only progression, persists phase rows, and re-prompts when a
7
+ * phase stalls for too many turns.
8
+ */
9
+
10
+ export type Phase = "research" | "blueprint" | "implement" | "validate";
11
+
12
+ export const PHASES = Object.freeze([
13
+ "research",
14
+ "blueprint",
15
+ "implement",
16
+ "validate",
17
+ ] as const satisfies readonly Phase[]);
18
+
19
+ export interface PhaseState {
20
+ readonly current: Phase;
21
+ readonly advancedAt: number; // turn number when this phase was entered (0-based)
22
+ readonly summary?: string;
23
+ }
24
+
25
+ export interface AdvancePhaseResult {
26
+ readonly ok: true;
27
+ readonly phase: Phase;
28
+ }
29
+
30
+ export interface AdvancePhaseFailure {
31
+ readonly ok: false;
32
+ readonly error: string;
33
+ readonly phase: Phase;
34
+ }
35
+
36
+ export type AdvancePhaseOutcome = AdvancePhaseResult | AdvancePhaseFailure;
37
+
38
+ /** PHASE_GATE_TURNS: if the model stays in one phase for this many turns, the engine re-prompts. */
39
+ export const PHASE_GATE_TURNS = 4;
40
+
41
+ /** Validate a phase transition. Only forward progression is allowed. */
42
+ export function advancePhase(
43
+ current: Phase | undefined,
44
+ target: string,
45
+ ): AdvancePhaseOutcome {
46
+ if (!PHASES.includes(target as Phase)) {
47
+ return {
48
+ ok: false,
49
+ error: `unknown phase '${target}'; valid phases: ${PHASES.join(", ")}`,
50
+ phase: current ?? "research",
51
+ };
52
+ }
53
+ const from = current ?? "research";
54
+ const currentIdx = PHASES.indexOf(from);
55
+ const targetIdx = PHASES.indexOf(target as Phase);
56
+ if (targetIdx <= currentIdx) {
57
+ return {
58
+ ok: false,
59
+ error: `cannot move backward from '${from}' to '${target}'`,
60
+ phase: from,
61
+ };
62
+ }
63
+ return { ok: true, phase: target as Phase };
64
+ }
65
+
66
+ /** Return the current phase (defaults to "research" if undefined). */
67
+ export function currentPhase(state: PhaseState | undefined): Phase {
68
+ return state?.current ?? "research";
69
+ }
70
+
71
+ /** Return the number of turns spent in the current phase. */
72
+ export function turnsInPhase(state: PhaseState | undefined, completedTurns: number): number {
73
+ return state ? completedTurns - state.advancedAt : completedTurns;
74
+ }
75
+
76
+ /** Produce a re-prompt message when the model stalls in a phase for too long. */
77
+ export function phaseGatePrompt(
78
+ state: PhaseState | undefined,
79
+ completedTurns: number,
80
+ ): string | undefined {
81
+ const turns = turnsInPhase(state, completedTurns);
82
+ const phase = currentPhase(state);
83
+ if (turns >= PHASE_GATE_TURNS) {
84
+ const next = nextPhase(phase);
85
+ const hint = next
86
+ ? ` Consider calling advance_phase("${next}") if your ${phase} work is complete.`
87
+ : "";
88
+ return [
89
+ `You have spent ${turns} turns in the '${phase}' phase.`,
90
+ `If the ${phase} phase is complete, advance to the next phase.${hint}`,
91
+ ].join(" ");
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ /** Return the next phase, or undefined if at the terminal phase. */
97
+ export function nextPhase(current: Phase): Phase | undefined {
98
+ const idx = PHASES.indexOf(current);
99
+ return idx >= 0 && idx < PHASES.length - 1 ? PHASES[idx + 1] : undefined;
100
+ }
@@ -0,0 +1,14 @@
1
+ /** Shared guards for inherited recursive-call resource limits. */
2
+
3
+ import { formatError } from "../util/errors.ts";
4
+
5
+ export interface RemainingResources {
6
+ readonly budgetUsd?: number;
7
+ readonly timeoutMs?: number;
8
+ }
9
+
10
+ export function checkResourceLimits(resources: RemainingResources): string | undefined {
11
+ if (resources.budgetUsd !== undefined && resources.budgetUsd <= 0) return formatError("budget exhausted");
12
+ if (resources.timeoutMs !== undefined && resources.timeoutMs <= 0) return formatError("timeout exhausted");
13
+ return undefined;
14
+ }