@hicaru/pi-rlm 0.3.5 → 0.3.8

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/openzebra/rlm.pi/master/assets/plugin-cover.png" width="100%" alt="pi-rlm — Recursive Language Model plugin for Pi">
2
+ <img src="https://github.com/openzebra/rlm.pi/blob/master/assets/plugin-cover.png?raw=true" width="100%" alt="pi-rlm — Recursive Language Model plugin for Pi">
3
3
  </p>
4
4
 
5
5
  <p align="center">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.3.5",
3
+ "version": "0.3.8",
4
4
  "author": "hicaru",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,7 +37,7 @@
37
37
  "extensions": [
38
38
  "./src/index.ts"
39
39
  ],
40
- "image": "https://raw.githubusercontent.com/openzebra/rlm.pi/master/assets/plugin-cover.png"
40
+ "image": "https://github.com/openzebra/rlm.pi/blob/master/assets/plugin-cover.png?raw=true"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"
@@ -6,11 +6,12 @@ import type { Usage } from "@earendil-works/pi-ai";
6
6
  import { modelRef } from "../../config/settings.ts";
7
7
  import { complete1, type Complete1Deps } from "./completion.ts";
8
8
  import { emitting, summarizeBatch } from "./emitting.ts";
9
- import { formatError, isErrorText } from "../../util/errors.ts";
9
+ import { formatError, isErrorText, errorMessage } from "../../util/errors.ts";
10
10
  import { previewText } from "../../text/preview.ts";
11
11
  import type { SpawnResult, SubcallHandlerDeps } from "./types.ts";
12
12
  import type { SubcallOpts } from "../../sandbox/interrupts.ts";
13
13
  import { SPAWN_HINT, spawnAndRun, type SpawnDeps } from "./task-registry.ts";
14
+ import { ECHO_STUB, taskKey, type TaskLedger } from "../../core/ledger.ts";
14
15
 
15
16
  const UNWIRED = formatError("RLM bridge not wired for this invocation");
16
17
 
@@ -34,6 +35,45 @@ function displayModel(deps: SubcallHandlerDeps): string | undefined {
34
35
  }
35
36
  }
36
37
 
38
+ /** The ledger active for leaf calls — undefined when disabled by config or not threaded in. */
39
+ function activeLedger(deps: SubcallHandlerDeps): TaskLedger | undefined {
40
+ return deps.getConfig().enableLedger ? deps.ledger : undefined;
41
+ }
42
+
43
+ /** v5 TaskLedger routing for ONE leaf prompt (audit H3 — shared by llm_query and every
44
+ * llm_batch item, which v5 routed through `_spawn_single` too): echo → stub string,
45
+ * coalesce → the twin's result (bounded wait), run → caller executes then finish/fail.
46
+ * Exported for tests. */
47
+ export async function runClaimedLeaf(
48
+ ledger: TaskLedger | undefined,
49
+ key: string | undefined,
50
+ prompt: string,
51
+ depth: number,
52
+ exec: () => Promise<string>,
53
+ ): Promise<string> {
54
+ if (ledger === undefined || key === undefined) return exec();
55
+ const decision = ledger.tryClaim({ kind: "llm", prompt, paths: [], depth }, key);
56
+ if (decision.type === "echo") return ECHO_STUB;
57
+ if (decision.type === "coalesce") {
58
+ return ledger.waitFor(decision.key).catch((err: unknown): string => formatError(errorMessage(err)));
59
+ }
60
+ ledger.markRunning(key);
61
+ try {
62
+ const out = await exec();
63
+ ledger.finish(key, out);
64
+ return out;
65
+ } catch (err: unknown) {
66
+ ledger.fail(key, errorMessage(err));
67
+ throw err;
68
+ }
69
+ }
70
+
71
+ export function leafClaimKey(deps: SubcallHandlerDeps, prompt: string): string | undefined {
72
+ const ledger = activeLedger(deps);
73
+ if (ledger === undefined) return undefined;
74
+ return taskKey("llm", prompt, [], displayModel(deps) ?? "", "");
75
+ }
76
+
37
77
  export function createLlmQueryHandler(
38
78
  deps: SubcallHandlerDeps,
39
79
  sd: SpawnDeps,
@@ -57,25 +97,28 @@ export function createLlmQueryHandler(
57
97
  }
58
98
 
59
99
  const cdeps = completeDeps(deps);
100
+ const runLeaf = (): Promise<string> =>
101
+ emitting(
102
+ inv,
103
+ {
104
+ kind: "llm",
105
+ label: "llm_query",
106
+ args: `prompt: ${previewText(prompt)}`,
107
+ model: displayModel(deps),
108
+ },
109
+ (track: (u: Usage) => void) => complete1(inv, prompt, track, cdeps),
110
+ (out) => ({
111
+ preview: previewText(out),
112
+ error: isErrorText(out) ? out : undefined,
113
+ }),
114
+ );
115
+ // v5 TaskLedger for leaves: identical prompts coalesce onto one completion (key has no
116
+ // context — a leaf's entire world is the prompt text itself).
60
117
  return spawnAndRun(
61
118
  sd,
62
119
  "llm",
63
120
  1,
64
- () =>
65
- emitting(
66
- inv,
67
- {
68
- kind: "llm",
69
- label: "llm_query",
70
- args: `prompt: ${previewText(prompt)}`,
71
- model: displayModel(deps),
72
- },
73
- (track: (u: Usage) => void) => complete1(inv, prompt, track, cdeps),
74
- (out) => ({
75
- preview: previewText(out),
76
- error: isErrorText(out) ? out : undefined,
77
- }),
78
- ),
121
+ () => runClaimedLeaf(activeLedger(deps), leafClaimKey(deps, prompt), prompt, inv.depth, runLeaf),
79
122
  deps.trackDetached,
80
123
  opts.detached,
81
124
  );
@@ -105,6 +148,7 @@ export function createLlmBatchHandler(
105
148
  }
106
149
 
107
150
  const cdeps = completeDeps(deps);
151
+ const ledger = activeLedger(deps);
108
152
  return spawnAndRun(
109
153
  sd,
110
154
  "llm_batch",
@@ -119,8 +163,20 @@ export function createLlmBatchHandler(
119
163
  model: displayModel(deps),
120
164
  },
121
165
  // NO outer gate — complete1 takes the single leaf slot per prompt.
166
+ // v5 (audit H3): every item routes through the ledger — duplicate prompts inside
167
+ // one batch (or twins of other in-flight leaves) coalesce instead of paying N times.
122
168
  (track: (u: Usage) => void) =>
123
- Promise.all(prompts.map((p) => complete1(inv, p, track, cdeps))),
169
+ Promise.all(
170
+ prompts.map((p) =>
171
+ runClaimedLeaf(
172
+ ledger,
173
+ ledger === undefined ? undefined : leafClaimKey(deps, p),
174
+ p,
175
+ inv.depth,
176
+ () => complete1(inv, p, track, cdeps),
177
+ ),
178
+ ),
179
+ ),
124
180
  summarizeBatch,
125
181
  ),
126
182
  deps.trackDetached,
@@ -10,10 +10,14 @@ import { filterContextByPaths } from "../../context/merge.ts";
10
10
  import { previewText } from "../../text/preview.ts";
11
11
  import type { RlmInput, RlmResult } from "../../core/types.ts";
12
12
  import { checkResourceLimits } from "../../core/resource-limits.ts";
13
+ import { contextSig, ECHO_STUB, taskKey } from "../../core/ledger.ts";
13
14
  import type { Invocation, SpawnResult, SubcallHandlerDeps } from "./types.ts";
14
15
  import type { SubcallOpts } from "../../sandbox/interrupts.ts";
15
16
  import { SPAWN_HINT, spawnAndRun, type SpawnDeps } from "./task-registry.ts";
16
17
  import { complete1, type Complete1Deps } from "./completion.ts";
18
+ import { emitting } from "./emitting.ts";
19
+ import { isErrorText } from "../../util/errors.ts";
20
+ import { leafClaimKey, runClaimedLeaf } from "./llm-query.ts";
17
21
 
18
22
  const UNWIRED = formatError("RLM bridge not wired for this invocation");
19
23
  const NO_UNMATCHED: readonly string[] = Object.freeze([]);
@@ -64,9 +68,20 @@ function completeDeps(deps: SubcallHandlerDeps): Complete1Deps {
64
68
  };
65
69
  }
66
70
 
71
+ /** Ledger active for this call — undefined when disabled by config or not threaded in. */
72
+ function activeLedger(deps: SubcallHandlerDeps) {
73
+ return deps.getConfig().enableLedger ? deps.ledger : undefined;
74
+ }
75
+
76
+ function claimKeyFor(deps: SubcallHandlerDeps, kind: "llm" | "rlm", prompt: string, paths: readonly string[], ctx: string): string {
77
+ const rootModel = deps.getModel?.();
78
+ const modelId = rootModel === undefined ? "" : (modelRef(rootModel) ?? rootModel.id);
79
+ return taskKey(kind, prompt, paths, modelId, ctx);
80
+ }
81
+
67
82
  /**
68
- * One child RLM run: depth cap → resource guard → depth gate → spawn engine → debit parent.
69
- * Emits its own subcall node (do not wrap in emitting()).
83
+ * One child RLM run: depth cap → resource guard → ledger gate → depth gate → spawn engine →
84
+ * debit parent. Emits its own subcall node (do not wrap in emitting()).
70
85
  */
71
86
  async function childRun(
72
87
  deps: SubcallHandlerDeps,
@@ -91,23 +106,74 @@ async function childRun(
91
106
  const limitError = checkResourceLimits({ timeoutMs: remTimeout });
92
107
  if (limitError !== undefined) return emptyResult(limitError);
93
108
 
109
+ const child = childContextFor(deps, prompt, paths);
110
+ const rootPrompt =
111
+ child.unmatched.length === 0
112
+ ? prompt
113
+ : `${prompt}\n\n[rlm] paths=${child.unmatched.join(", ")} matched no files; you received the full context.`;
114
+
115
+ // ── v5 memory replay: an identical, still-fresh child answer replays for zero API calls ──
116
+ const memory = deps.memory;
117
+ const sig = contextSig(child.context);
118
+ const key = claimKeyFor(deps, "rlm", prompt, paths ?? [], sig);
119
+ if (memory !== undefined && deps.getConfig().enableMemory !== false) {
120
+ const hit = memory.replay(key);
121
+ if (hit !== undefined) {
122
+ const replayId = inv.emitter.emitSubcallCreated({
123
+ kind: "rlm",
124
+ parentId: inv.parentId,
125
+ label: "rlm_query (replay)",
126
+ detail: prompt.slice(0, 60),
127
+ depth: childDepth,
128
+ });
129
+ inv.emitter.emitSubcallUpdated({ id: replayId, status: "done", resultPreview: hit.result.slice(0, 200) });
130
+ return {
131
+ answer: hit.result,
132
+ iterations: 0,
133
+ costUsd: 0,
134
+ inputTokens: 0,
135
+ outputTokens: 0,
136
+ durationMs: 0,
137
+ };
138
+ }
139
+ }
140
+
141
+ // ── v5 TaskLedger: echo → stub; duplicate → coalesce onto the existing runner ──────
142
+ const ledger = activeLedger(deps);
143
+ const claimKey = ledger === undefined ? undefined : key;
144
+ const decision =
145
+ ledger !== undefined && claimKey !== undefined
146
+ ? ledger.tryClaim({ kind: "rlm", prompt, paths: paths ?? [], depth: childDepth }, claimKey)
147
+ : undefined;
148
+
149
+ // ONE subcall node per childRun (audit C2 / DRY #5): the decision branch reuses it, the
150
+ // run branch reports the engine's turns/cost on it. Never a second emit below.
94
151
  const rootModel = deps.getModel?.();
95
152
  const modelLabel =
96
153
  rootModel === undefined ? undefined : (modelRef(rootModel) ?? rootModel.id);
97
154
  const subId = inv.emitter.emitSubcallCreated({
98
155
  kind: "rlm",
99
156
  parentId: inv.parentId,
100
- label: "rlm_query",
157
+ label: decision === undefined || decision.type === "run" ? "rlm_query" : `rlm_query (${decision.type})`,
101
158
  model: modelLabel,
102
159
  detail: prompt.slice(0, 60),
103
160
  depth: childDepth,
104
161
  });
105
162
 
106
- const child = childContextFor(deps, prompt, paths);
107
- const rootPrompt =
108
- child.unmatched.length === 0
109
- ? prompt
110
- : `${prompt}\n\n[rlm] paths=${child.unmatched.join(", ")} matched no files; you received the full context.`;
163
+ if (decision?.type === "echo") {
164
+ inv.emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: ECHO_STUB.slice(0, 80) });
165
+ return emptyResult(ECHO_STUB);
166
+ }
167
+ if (decision?.type === "coalesce" && ledger !== undefined) {
168
+ const twin = await ledger
169
+ .waitFor(decision.key)
170
+ .catch((err: unknown) => errorMessage(err));
171
+ inv.emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: previewText(String(twin).slice(0, 80)) });
172
+ return emptyResult(String(twin));
173
+ }
174
+ if (ledger !== undefined && claimKey !== undefined) {
175
+ ledger.markRunning(claimKey);
176
+ }
111
177
 
112
178
  const input: RlmInput = {
113
179
  rootPrompt,
@@ -115,12 +181,27 @@ async function childRun(
115
181
  depth: childDepth,
116
182
  parentNodeId: subId,
117
183
  remainingTimeoutMs: remTimeout,
184
+ ledger, // DRY #6: the one seam — children share the parent's blackboard
118
185
  };
119
186
 
120
187
  try {
121
188
  const res = await deps.gates.rlm.at(childDepth).run(() => run(input, inv));
122
189
  inv.limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
123
190
  deps.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
191
+ if (ledger !== undefined && claimKey !== undefined) ledger.finish(claimKey, res.answer);
192
+ // v5: child answers persist unconditionally — this is what later identical runs replay.
193
+ if (memory !== undefined && deps.getConfig().enableMemory !== false) {
194
+ memory.recordEpisode({
195
+ key,
196
+ kind: "rlm",
197
+ model: modelLabel ?? "",
198
+ prompt,
199
+ paths: paths ?? [],
200
+ result: res.answer,
201
+ tokensIn: res.inputTokens,
202
+ tokensOut: res.outputTokens,
203
+ });
204
+ }
124
205
  inv.emitter.emitSubcallUpdated({
125
206
  id: subId,
126
207
  status: "done",
@@ -129,6 +210,7 @@ async function childRun(
129
210
  return res;
130
211
  } catch (err: unknown) {
131
212
  const msg = errorMessage(err);
213
+ if (ledger !== undefined && claimKey !== undefined) ledger.fail(claimKey, msg);
132
214
  inv.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
133
215
  return emptyResult(formatError(`child RLM failed - ${msg}`));
134
216
  }
@@ -155,6 +237,46 @@ export function createRlmQueryHandler(deps: SubcallHandlerDeps, sd: SpawnDeps) {
155
237
 
156
238
  const pathArg = opts.paths;
157
239
 
240
+ // v5 rlmBudget demotion: once the ledger has started `rlmBudget` real rlm runs, extra
241
+ // rlm_query spawns demote to the leaf path (batch spawns are exempt — v5 `_spawn_single`).
242
+ const ledger = activeLedger(deps);
243
+ const rlmBudget = deps.getConfig().rlmBudget;
244
+ if (
245
+ ledger !== undefined &&
246
+ rlmBudget !== undefined &&
247
+ rlmBudget > 0 &&
248
+ ledger.rlmCount() >= rlmBudget
249
+ ) {
250
+ return spawnAndRun(
251
+ sd,
252
+ "llm",
253
+ 1,
254
+ () =>
255
+ runClaimedLeaf(
256
+ ledger,
257
+ leafClaimKey(deps, task),
258
+ task,
259
+ inv.depth,
260
+ () =>
261
+ emitting(
262
+ inv,
263
+ {
264
+ kind: "llm",
265
+ label: "rlm_query→llm (demoted)",
266
+ args: previewText(task),
267
+ },
268
+ (track) => complete1(inv, task, track, completeDeps(deps)),
269
+ (out) => ({
270
+ preview: previewText(out),
271
+ error: isErrorText(out) ? out : undefined,
272
+ }),
273
+ ),
274
+ ),
275
+ deps.trackDetached,
276
+ opts.detached,
277
+ );
278
+ }
279
+
158
280
  return spawnAndRun(
159
281
  sd,
160
282
  "rlm",
@@ -12,6 +12,8 @@ import type { RlmInput, RlmResult, Sampling } from "../../core/types.ts";
12
12
  import type { SubcallGates } from "../../util/concurrency.ts";
13
13
  import type { SubcallOpts } from "../../sandbox/interrupts.ts";
14
14
  import type { RlmEmitter } from "../../tool/rlm-events.ts";
15
+ import type { TaskLedger } from "../../core/ledger.ts";
16
+ import type { MemoryStore } from "../../core/memory.ts";
15
17
 
16
18
  // ---------------------------------------------------------------------------
17
19
  // Spawn / Await / Finish — the three shapes the model sees
@@ -85,6 +87,12 @@ export interface SubcallConfig {
85
87
  readonly maxDepth: number;
86
88
  readonly subSampling?: Sampling;
87
89
  readonly subSystemPrompt?: string;
90
+ /** v5 TaskLedger: claim/coalesce/echo gates (optional — unwired callers keep ledger off). */
91
+ readonly enableLedger?: boolean;
92
+ /** v5: real rlm spawns before demotion to llm (0 = never demote). */
93
+ readonly rlmBudget?: number;
94
+ /** v5 durable memory gates (optional; omitted → memory off). */
95
+ readonly enableMemory?: boolean;
88
96
  }
89
97
 
90
98
  export interface SubcallHandlerDeps {
@@ -104,6 +112,10 @@ export interface SubcallHandlerDeps {
104
112
  readonly degrade?: (prompt: string, depth: number) => Promise<string>;
105
113
  readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
106
114
  readonly trackDetached?: <T>(run: () => Promise<T>) => Promise<T>;
115
+ /** v5 TaskLedger blackboard shared across the whole run tree (claim/coalesce/echo/demote). */
116
+ readonly ledger?: TaskLedger;
117
+ /** v5 durable memory (session-wide store) for child replay + episode persistence. */
118
+ readonly memory?: MemoryStore;
107
119
  }
108
120
 
109
121
  // ---------------------------------------------------------------------------
@@ -31,4 +31,21 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
31
31
  rootSampling: Object.freeze({ maxTokens: 16_384 }),
32
32
  subSystemPrompt: DEFAULT_SUB_SYSTEM_PROMPT,
33
33
  subSampling: Object.freeze({ maxTokens: 8192 }),
34
+ // v5 token budget cascade — the primary run-length control (wall-clock stays a hang backstop).
35
+ enableTokenBudget: true,
36
+ budgetShare: 0.25,
37
+ budgetSoftFrac: 0.8,
38
+ budgetTaskCap: 400_000,
39
+ budgetMaxContinuations: 2,
40
+ budgetHandoffChars: 4_000,
41
+ // v5 TaskLedger blackboard
42
+ enableLedger: true,
43
+ rlmBudget: 8,
44
+ // v5 durable memory
45
+ enableMemory: true,
46
+ injectNoteTokens: 2_000,
47
+ evolveEvery: 8,
48
+ memoryDir: null,
49
+ // v5 role separation: children delegate (llm + memory/ledger); "legacy" = full child surface.
50
+ childSurface: "delegation",
34
51
  });
@@ -45,7 +45,9 @@ function validateThinkingLevel(v: unknown): ThinkingLevel | undefined {
45
45
  return typeof v === "string" && Object.hasOwn(THINKING_LEVELS, v) ? (v as ThinkingLevel) : undefined;
46
46
  }
47
47
 
48
- function validateConfig(raw: unknown): Partial<RlmConfig> {
48
+ /** Validate an unknown (e.g. hand-edited rlm.json) config blob into a partial — the single
49
+ * validation seam; exported for tests. */
50
+ export function validateConfig(raw: unknown): Partial<RlmConfig> {
49
51
  if (typeof raw !== "object" || raw === null) return {};
50
52
  const r = raw as Record<string, unknown>;
51
53
  const out: MutablePartialRlmConfig = {};
@@ -90,6 +92,47 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
90
92
  if (contextLoader !== undefined) out.contextLoader = contextLoader;
91
93
  const autoSeedCwd = validateBoolean(r.autoSeedCwd);
92
94
  if (autoSeedCwd !== undefined) out.autoSeedCwd = autoSeedCwd;
95
+ // v5 token budget cascade
96
+ const enableTokenBudget = validateBoolean(r.enableTokenBudget);
97
+ if (enableTokenBudget !== undefined) out.enableTokenBudget = enableTokenBudget;
98
+ const budgetShare = validateNumber(r.budgetShare, 0.01);
99
+ if (budgetShare !== undefined && budgetShare <= 1) out.budgetShare = budgetShare;
100
+ const budgetSoftFrac = validateNumber(r.budgetSoftFrac, 0.5);
101
+ if (budgetSoftFrac !== undefined && budgetSoftFrac < 1) out.budgetSoftFrac = budgetSoftFrac;
102
+ const budgetTaskCap = validateNumber(r.budgetTaskCap, 0);
103
+ if (budgetTaskCap !== undefined) out.budgetTaskCap = budgetTaskCap;
104
+ const budgetMaxContinuations = validateNumber(r.budgetMaxContinuations, 0);
105
+ if (budgetMaxContinuations !== undefined) out.budgetMaxContinuations = budgetMaxContinuations;
106
+ const budgetHandoffChars = validateNumber(r.budgetHandoffChars, 500);
107
+ if (budgetHandoffChars !== undefined) out.budgetHandoffChars = budgetHandoffChars;
108
+ // v5 TaskLedger blackboard
109
+ const enableLedger = validateBoolean(r.enableLedger);
110
+ if (enableLedger !== undefined) out.enableLedger = enableLedger;
111
+ const rlmBudget = validateNumber(r.rlmBudget, 0);
112
+ if (rlmBudget !== undefined) out.rlmBudget = rlmBudget;
113
+ // v5 durable memory
114
+ const enableMemory = validateBoolean(r.enableMemory);
115
+ if (enableMemory !== undefined) out.enableMemory = enableMemory;
116
+ const injectNoteTokens = validateNumber(r.injectNoteTokens, 100);
117
+ if (injectNoteTokens !== undefined) out.injectNoteTokens = injectNoteTokens;
118
+ const evolveEvery = validateNumber(r.evolveEvery, 0);
119
+ if (evolveEvery !== undefined) out.evolveEvery = evolveEvery;
120
+ if (r.memoryDir === null) out.memoryDir = null;
121
+ else {
122
+ const memoryDir = validateString(r.memoryDir);
123
+ if (memoryDir !== undefined) out.memoryDir = memoryDir;
124
+ }
125
+ // v5 provider concurrency caps: { provider: minConcurrent }
126
+ if (typeof r.providerMaxConcurrent === "object" && r.providerMaxConcurrent !== null) {
127
+ const caps: Record<string, number> = {};
128
+ for (const [provider, cap] of Object.entries(r.providerMaxConcurrent as Record<string, unknown>)) {
129
+ const n = validateNumber(cap, 1);
130
+ if (n !== undefined) caps[provider] = n;
131
+ }
132
+ if (Object.keys(caps).length > 0) out.providerMaxConcurrent = Object.freeze(caps);
133
+ }
134
+ // v5 child surface doctrine
135
+ if (r.childSurface === "delegation" || r.childSurface === "legacy") out.childSurface = r.childSurface;
93
136
  if (typeof r.subSampling === "object" && r.subSampling !== null) {
94
137
  const ss = r.subSampling as Record<string, unknown>;
95
138
  const sampling: { maxTokens?: number; temperature?: number; reasoning?: ThinkingLevel } = {};
@@ -99,7 +142,7 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
99
142
  if (temperature !== undefined) sampling.temperature = temperature;
100
143
  const ssReasoning = validateThinkingLevel(ss.reasoning);
101
144
  if (ssReasoning !== undefined) sampling.reasoning = ssReasoning;
102
- out.subSampling = sampling;
145
+ out.subSampling = Object.freeze(sampling);
103
146
  }
104
147
  if (typeof r.rootSampling === "object" && r.rootSampling !== null) {
105
148
  const rs = r.rootSampling as Record<string, unknown>;
@@ -1,7 +1,7 @@
1
1
  /** Helpers for detecting and formatting the RLM final answer from a turn's REPL results. */
2
2
 
3
3
  import type { ReplResult } from "../sandbox/protocol.ts";
4
- import { truncateOutput } from "../text/parsing.ts";
4
+ import { formatReplStderr } from "../text/repl-output.ts";
5
5
 
6
6
  /** First non-null final answer across a turn's executed blocks, or null. */
7
7
  export function finalAnswerOf(results: readonly ReplResult[]): string | null {
@@ -29,9 +29,10 @@ export function turnHadError(results: readonly ReplResult[]): boolean {
29
29
  const SMALL_STDOUT_LIMIT = 800;
30
30
  const STDOUT_PREVIEW_LIMIT = 200;
31
31
  const STDOUT_TAIL_LIMIT = 200;
32
- const STDERR_LIMIT = 8_000;
33
32
 
34
- /** The REPL output fed back to the model as the next user message. */
33
+ /** The REPL output fed back to the model as the next user message. Prefixed `REPL stdout:`
34
+ * (v5 parity, audit C4): `distillTrajectory` keys on this needle to harvest the working
35
+ * set for a budget-capped continuation — without it a hard-cap chain starts blind. */
35
36
  export function formatReplOutputs(results: readonly ReplResult[], skippedBlocks = 0): string {
36
37
  if (results.length === 0) {
37
38
  return "No ```repl``` block found in your response. Write one to interact with the REPL.";
@@ -44,21 +45,21 @@ export function formatReplOutputs(results: readonly ReplResult[], skippedBlocks
44
45
  const head = multi ? `[block ${i + 1}]\n` : "";
45
46
  const { text, elided } = formatStdout(r);
46
47
  hadElision ||= elided;
47
- parts[i] = `${head}${text}${formatStderr(r)}`;
48
+ parts[i] = `${head}${text}${formatReplStderr(r.stderr)}`;
48
49
  }
49
50
  const body = parts.join("\n\n");
50
51
  const skipNote = skippedBlocks > 0
51
52
  ? `\n\n[${skippedBlocks} later \`\`\`repl\`\`\` block(s) skipped because an earlier block raised — fix and re-run them]`
52
53
  : "";
53
54
  // Orientation hint only when the model lost output to elision — otherwise it sees everything.
54
- if (!hadElision) return `${body}${skipNote}`;
55
+ if (!hadElision) return `REPL stdout:\n${body}${skipNote}`;
55
56
  // The REPL namespace is persistent across blocks in a turn, so the last block's varNames reflect
56
57
  // every variable created in any earlier block too.
57
58
  const varNames = results.at(-1)?.varNames ?? [];
58
59
  const hint = varNames.length > 0
59
60
  ? `REPL vars: ${varNames.join(", ")}`
60
61
  : `No REPL vars yet — assign results to variables before printing large outputs.`;
61
- return `${body}${skipNote}\n\n${hint}`;
62
+ return `REPL stdout:\n${body}${skipNote}\n\n${hint}`;
62
63
  }
63
64
 
64
65
  /** Stdout ≤ SMALL_STDOUT_LIMIT flows through verbatim; larger output keeps a short head + a note
@@ -78,7 +79,3 @@ function formatStdout(r: ReplResult): { text: string; elided: boolean } {
78
79
  };
79
80
  }
80
81
 
81
- function formatStderr(r: ReplResult): string {
82
- const err = r.stderr.trim();
83
- return err ? `\n[stderr]\n${truncateOutput(err, STDERR_LIMIT)}` : "";
84
- }