@hicaru/pi-rlm 0.3.19 → 0.3.20

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
@@ -48,9 +48,11 @@ journal per model, cost per task from real `costUsd`:
48
48
 
49
49
  | Model | Score | Avg. cost/task |
50
50
  |-------|-------|----------------|
51
- | `qwen/qwen3.8-27b` | **87.5%** | $0.0460 |
52
- | `google/gemma-3-27b-it` | 50.0% | $0.0011 |
53
- | `inception/mercury-2.5` | | |
51
+ | `zai/glm-4.7` | **83%** | $0.0000 * |
52
+ | `qwen/qwen3.8-27b` | 49% | $0.1038 |
53
+ | `inception/mercury-2.5` | 38% | $0.0052 |
54
+
55
+ \* glm-4.7 runs on Z.ai's coding-plan endpoint — subscription billing, `costUsd` stays $0.
54
56
 
55
57
  Raw per-task rows (correct, recall, latency, tokens, cost) live in
56
58
  `bench/runs/*.jsonl` — one JSONL row per task, committed as history.
@@ -58,10 +60,11 @@ Raw per-task rows (correct, recall, latency, tokens, cost) live in
58
60
  ### Run the benchmarks
59
61
 
60
62
  ```bash
61
- export OPENROUTER_API_KEY=sk-or-... # required env vars are the only key transport
63
+ export OPENROUTER_API_KEY=sk-or-... # required for openrouter/* models
64
+ export ZAI_API_KEY=... # required for zai/* models (coding endpoint)
62
65
 
63
66
  bun run bench # oolong suite, default model (qwen3.8-27b)
64
- bun run bench --model openrouter/google/gemma-3-27b-it
67
+ bun run bench --model zai/glm-4.7
65
68
  bun run bench --model openrouter/inception/mercury-2.5
66
69
  bun run bench --list # print tasks, no engine / no key
67
70
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "author": "hicaru",
5
5
  "repository": {
6
6
  "type": "git",
@@ -83,6 +83,9 @@ export async function complete1(
83
83
  onThrottlePark: hooks?.onThrottlePark,
84
84
  onThrottleRelease: hooks?.onThrottleRelease,
85
85
  signal: deps.signal,
86
+ // Long-context providers (zai bigmodel TTFB ~1 min per 10k ctx chars) need the
87
+ // per-request wall cap raised from the pi-ai default.
88
+ timeoutMs: config.requestTimeoutMs,
86
89
  }),
87
90
  );
88
91
  inv.limits.addUsage(res.usage);
@@ -54,12 +54,10 @@ export async function emitting<T>(
54
54
  inv.emitter.emitSubcallUpdated({ id, ...u });
55
55
  };
56
56
 
57
- let costUsd = 0;
58
57
  let tokens = 0;
59
58
  let tokensIn = 0;
60
59
  let tokensOut = 0;
61
60
  const track = (u: Usage): void => {
62
- costUsd += u.cost.total;
63
61
  tokens += u.totalTokens;
64
62
  tokensIn += u.input;
65
63
  tokensOut += u.output;
@@ -72,7 +70,6 @@ export async function emitting<T>(
72
70
  id,
73
71
  status: summary.error !== undefined ? "error" : "done",
74
72
  resultPreview: summary.preview,
75
- costUsd,
76
73
  tokens,
77
74
  tokensIn,
78
75
  tokensOut,
@@ -87,7 +84,6 @@ export async function emitting<T>(
87
84
  id,
88
85
  status: "error",
89
86
  resultPreview: msg,
90
- costUsd,
91
87
  tokens,
92
88
  tokensIn,
93
89
  tokensOut,
@@ -24,10 +24,10 @@ function emptyResult(answer: string): RlmResult {
24
24
  return {
25
25
  answer,
26
26
  iterations: 0,
27
- costUsd: 0,
28
27
  inputTokens: 0,
29
28
  outputTokens: 0,
30
29
  durationMs: 0,
30
+ lastStdout: "",
31
31
  };
32
32
  }
33
33
 
@@ -149,8 +149,8 @@ async function childRun(
149
149
 
150
150
  try {
151
151
  const res = await deps.gates.rlm.at(childDepth).run(() => run(input, inv));
152
- inv.limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
153
- deps.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
152
+ inv.limits.addRaw(res.inputTokens, res.outputTokens);
153
+ deps.onChildUsage?.(res.inputTokens, res.outputTokens);
154
154
  if (ledger !== undefined && claimKey !== undefined) ledger.finish(claimKey, res.answer);
155
155
  inv.emitter.emitSubcallUpdated({
156
156
  id: subId,
@@ -54,17 +54,19 @@ interface Waiter {
54
54
  timer?: ReturnType<typeof setTimeout>;
55
55
  }
56
56
 
57
- function notify(waiters: Map<string, Waiter>, taskId: string, entry: TaskEntry): void {
58
- const w = waiters.get(taskId);
59
- if (w === undefined) return;
60
- if (w.timer !== undefined) clearTimeout(w.timer);
61
- w.resolve(entry);
57
+ function notify(waiters: Map<string, Waiter[]>, taskId: string, entry: TaskEntry): void {
58
+ const list = waiters.get(taskId);
59
+ if (list === undefined) return;
62
60
  waiters.delete(taskId);
61
+ for (const w of list) {
62
+ if (w.timer !== undefined) clearTimeout(w.timer);
63
+ w.resolve(entry);
64
+ }
63
65
  }
64
66
 
65
67
  export function createTaskRegistry(): TaskRegistry {
66
68
  const tasks = new Map<string, TaskEntry>();
67
- const waiters = new Map<string, Waiter>();
69
+ const waiters = new Map<string, Waiter[]>();
68
70
  let counter = 0;
69
71
 
70
72
  const spawnDeps: SpawnDeps = {
@@ -92,7 +94,9 @@ export function createTaskRegistry(): TaskRegistry {
92
94
  },
93
95
  resolve(taskId, result) {
94
96
  const entry = tasks.get(taskId);
95
- if (entry === undefined) return;
97
+ // Settle-once: a late resolve after a timeout/reject must not flip status or
98
+ // double-notify; the entry is terminal the moment it leaves "pending".
99
+ if (entry === undefined || entry.status !== "pending") return;
96
100
  entry.status = "done";
97
101
  if (typeof result === "string") {
98
102
  entry.result = result;
@@ -103,14 +107,17 @@ export function createTaskRegistry(): TaskRegistry {
103
107
  },
104
108
  reject(taskId, error) {
105
109
  const entry = tasks.get(taskId);
106
- if (entry === undefined) return;
110
+ // Settle-once (mirror of resolve): reject after resolve/timeout is a no-op.
111
+ if (entry === undefined || entry.status !== "pending") return;
107
112
  entry.status = "error";
108
113
  entry.error = error;
109
- const w = waiters.get(taskId);
110
- if (w !== undefined) {
111
- if (w.timer !== undefined) clearTimeout(w.timer);
112
- w.reject(new Error(error));
114
+ const list = waiters.get(taskId);
115
+ if (list !== undefined) {
113
116
  waiters.delete(taskId);
117
+ for (const w of list) {
118
+ if (w.timer !== undefined) clearTimeout(w.timer);
119
+ w.reject(new Error(error));
120
+ }
114
121
  }
115
122
  },
116
123
  };
@@ -124,19 +131,39 @@ export function createTaskRegistry(): TaskRegistry {
124
131
  resolve(entry);
125
132
  return;
126
133
  }
127
- const timer =
134
+ const w: Waiter = { resolve, reject };
135
+ w.timer =
128
136
  timeoutMs !== undefined
129
137
  ? setTimeout(() => {
130
- waiters.delete(taskId);
131
- const e = tasks.get(taskId);
132
- if (e !== undefined && e.status === "pending") {
133
- e.status = "timeout";
134
- e.error = `Timeout after ${timeoutMs}ms`;
138
+ w.timer = undefined;
139
+ // Remove only THIS waiter — a sibling wait() on the same task stays parked.
140
+ const list = waiters.get(taskId);
141
+ let lastWaiter = true;
142
+ if (list !== undefined) {
143
+ const i = list.indexOf(w);
144
+ if (i >= 0) {
145
+ list.splice(i, 1);
146
+ lastWaiter = list.length === 0;
147
+ if (lastWaiter) waiters.delete(taskId);
148
+ }
149
+ }
150
+ // Timeout is a per-waiter event, not a task property: only when the LAST
151
+ // waiter gives up does the shared entry record "timeout" (settle-once then
152
+ // keeps a late resolve from resurrecting it). While a sibling stays parked,
153
+ // the entry remains pending and resolve() still wakes it.
154
+ if (lastWaiter) {
155
+ const e = tasks.get(taskId);
156
+ if (e !== undefined && e.status === "pending") {
157
+ e.status = "timeout";
158
+ e.error = `Timeout after ${timeoutMs}ms`;
159
+ }
135
160
  }
136
161
  reject(new Error(`Timeout waiting for task ${taskId}`));
137
162
  }, timeoutMs)
138
163
  : undefined;
139
- waiters.set(taskId, { resolve, reject, timer });
164
+ const list = waiters.get(taskId);
165
+ if (list === undefined) waiters.set(taskId, [w]);
166
+ else list.push(w);
140
167
  });
141
168
  },
142
169
  unawaitedIds: () => {
@@ -57,7 +57,7 @@ export interface FinishResult {
57
57
  export interface InvocationLimits {
58
58
  remainingTimeoutMs(): number | undefined;
59
59
  addUsage(usage: Usage): void;
60
- addRaw(costUsd: number, inputTokens: number, outputTokens: number): void;
60
+ addRaw(inputTokens: number, outputTokens: number): void;
61
61
  }
62
62
 
63
63
  export function limitsFromRemaining(
@@ -66,7 +66,7 @@ export function limitsFromRemaining(
66
66
  return Object.freeze({
67
67
  remainingTimeoutMs: () => remaining?.().timeoutMs,
68
68
  addUsage: (_usage: Usage) => {},
69
- addRaw: (_costUsd: number, _inputTokens: number, _outputTokens: number) => {},
69
+ addRaw: (_inputTokens: number, _outputTokens: number) => {},
70
70
  });
71
71
  }
72
72
 
@@ -86,6 +86,9 @@ export interface SubcallConfig {
86
86
  readonly maxDepth: number;
87
87
  readonly subSampling?: Sampling;
88
88
  readonly subSystemPrompt?: string;
89
+ /** Per-request wall cap (ms) for the llm tier — long-context providers (zai bigmodel TTFB
90
+ * ~1 min per 10k ctx chars) abort under the pi-ai default without it. */
91
+ readonly requestTimeoutMs: number;
89
92
  /** v5 TaskLedger: claim/coalesce/echo gates (optional — unwired callers keep ledger off). */
90
93
  readonly enableLedger?: boolean;
91
94
  /** v5: real rlm spawns before demotion to llm (0 = never demote). */
@@ -114,7 +117,7 @@ export interface SubcallHandlerDeps {
114
117
  readonly getChildContext?: () => unknown;
115
118
  readonly getModel?: () => Model<Api>;
116
119
  readonly degrade?: (prompt: string, depth: number) => Promise<string>;
117
- readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
120
+ readonly onChildUsage?: (inputTokens: number, outputTokens: number) => void;
118
121
  readonly trackDetached?: <T>(run: () => Promise<T>) => Promise<T>;
119
122
  /** v5 TaskLedger blackboard shared across the whole run tree (claim/coalesce/echo/demote). */
120
123
  readonly ledger?: TaskLedger;
@@ -28,6 +28,9 @@ export interface CompleteOptions {
28
28
  readonly temperature?: number;
29
29
  readonly reasoning?: ThinkingLevel;
30
30
  readonly signal?: AbortSignal;
31
+ /** Wall-clock cap for ONE provider request (ms); omitted = pi-ai default. Long-context
32
+ * providers (zai bigmodel: ~1 min per 10k ctx chars TTFB) need this raised per config. */
33
+ readonly timeoutMs?: number;
31
34
  /** Retry + adaptive throttle for transient 429/5xx; defaults apply when omitted. */
32
35
  readonly retry?: RetryPolicy;
33
36
  /** v5.1 UX: fired while parked on the rate-limit cooldown ("queued") / when released. */
@@ -116,6 +119,7 @@ export async function modelComplete(messages: readonly ChatMsg[], opts: Complete
116
119
  temperature: opts.temperature,
117
120
  reasoning: effectiveReasoning(opts.model, opts.reasoning),
118
121
  signal: opts.signal,
122
+ timeoutMs: opts.timeoutMs,
119
123
  onResponse: (res) => { note(res.status, res.headers); },
120
124
  },
121
125
  );
@@ -12,7 +12,10 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
12
12
  // Max-long runs: the engine may keep iterating until budget/compaction walls hit. The budget
13
13
  // cascade and compactionThresholdPct are the real length controls; this ceiling only stops
14
14
  // truly runaway loops. Was 30 — capped long tasks prematurely.
15
- maxIterations: 200,
15
+ // Strongly oversized on purpose: runs end on FINAL answer / errors / wall-clock long
16
+ // before this bites. History: 30 capped long tasks; the bench's 16 all-failed oolong
17
+ // mid-retrieval. 1000 ≈ 5× the old interactive default, effectively a runaway backstop.
18
+ maxIterations: 1_000,
16
19
  execTimeoutS: 120,
17
20
  requestTimeoutMs: 15 * 60_000,
18
21
  // Session-wide, not per-batch: spawn() puts many requests on the wire at once, so this is
@@ -53,9 +56,11 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
53
56
  enableTokenBudget: true,
54
57
  budgetShare: 0.25,
55
58
  budgetSoftFrac: 0.8,
56
- budgetTaskCap: 400_000,
59
+ budgetTaskCap: 1_000_000,
57
60
  budgetMaxContinuations: 2,
58
- budgetHandoffChars: 4_000,
61
+ // 24K: the handoff must carry Σ + findings + query verbatim — a 4K skeleton is what made
62
+ // long research runs "lose context" on hard-budget continuation (amputated, not lost).
63
+ budgetHandoffChars: 24_000,
59
64
  // v5 TaskLedger blackboard
60
65
  enableLedger: true,
61
66
  rlmBudget: 8,
@@ -197,11 +197,9 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
197
197
  }
198
198
  if (typeof r.rootSampling === "object" && r.rootSampling !== null) {
199
199
  const rs = r.rootSampling as Record<string, unknown>;
200
- const rootSampling: { maxTokens?: number; temperature?: number; reasoning?: ThinkingLevel } = {};
200
+ const rootSampling: { maxTokens?: number; reasoning?: ThinkingLevel } = {};
201
201
  const rsMaxTokens = validateNumber(rs.maxTokens, 1);
202
202
  if (rsMaxTokens !== undefined) rootSampling.maxTokens = rsMaxTokens;
203
- const rsTemperature = validateNumber(rs.temperature, 0);
204
- if (rsTemperature !== undefined) rootSampling.temperature = rsTemperature;
205
203
  const rsReasoning = validateThinkingLevel(rs.reasoning);
206
204
  if (rsReasoning !== undefined) rootSampling.reasoning = rsReasoning;
207
205
  out.rootSampling = Object.freeze(rootSampling);
@@ -37,12 +37,19 @@ export function filterContextByPaths(context: unknown, prefixes: readonly string
37
37
  for (let i = 0; i < context.length; i++) {
38
38
  const entry: unknown = context[i];
39
39
  if (!isContextFile(entry)) continue;
40
+ let matched = false;
40
41
  for (let p = 0; p < prefixes.length; p++) {
41
- if (!entry.path.startsWith(prefixes[p])) continue;
42
+ // Path-boundary match: exact file, or a directory prefix ending at a separator.
43
+ // Bare startsWith would let "src/cont" match "src/context/x.ts" (sibling leak).
44
+ const prefix = prefixes[p];
45
+ const bounded = prefix.endsWith("/") ? prefix : `${prefix}/`;
46
+ if (entry.path !== prefix && !entry.path.startsWith(bounded)) continue;
42
47
  hit[p] = true;
43
- out[n++] = entry;
44
- break;
48
+ matched = true;
45
49
  }
50
+ // Emit once even when several prefixes matched the same file (exact + its dir),
51
+ // but every matching prefix still counts as "matched" for the unmatched report.
52
+ if (matched) out[n++] = entry;
46
53
  }
47
54
  out.length = n;
48
55
  const unmatched = new Array<string>(prefixes.length); // pre-allocated, no .push()
@@ -126,8 +126,12 @@ export function namespaceContextFiles(
126
126
  return namespaceContextFilesWithChars(payload, sourceId).files;
127
127
  }
128
128
 
129
+ /** Regex SOURCE for the `ctx/<id>/` namespace — single literal; CTX_PREFIX_RE and the
130
+ * sandbox exec-code (Python, refresh.ts) both derive from it. Never inline a copy. */
131
+ export const CTX_PREFIX_PATTERN_SOURCE = "ctx/[^/]+/";
132
+
129
133
  /** The one `ctx/<id>/` matcher. Never re-declare this regex; use the helpers below. */
130
- const CTX_PREFIX_RE = /^(ctx\/[^/]+\/)/;
134
+ const CTX_PREFIX_RE = new RegExp(`^(${CTX_PREFIX_PATTERN_SOURCE})`);
131
135
 
132
136
  /** Narrow an unknown context entry to a ContextFile. Type guard, never a cast. */
133
137
  export function isContextFile(entry: unknown): entry is ContextFile {
@@ -142,7 +146,7 @@ export function contextEntryPath(entry: unknown): string | undefined {
142
146
  }
143
147
 
144
148
  /** The `ctx/<id>/` prefix owning this path, or undefined. Skips the legacy catch-all. */
145
- function ctxPrefixOf(path: string): string | undefined {
149
+ export function ctxPrefixOf(path: string): string | undefined {
146
150
  const prefix = CTX_PREFIX_RE.exec(path)?.[1];
147
151
  // `ctx/unknown/` is the legacy catch-all: never treat it as an identity.
148
152
  return prefix === undefined || prefix === LEGACY_UNKNOWN_PREFIX ? undefined : prefix;
@@ -7,6 +7,7 @@
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { isAbsolute, relative, resolve } from "node:path";
9
9
  import { estimateTokens } from "../text/tokens.ts";
10
+ import { ctxPrefixOf } from "./namespace.ts";
10
11
  import type { ContextFile } from "./types.ts";
11
12
 
12
13
  /** Paths that look like tool file targets. */
@@ -38,8 +39,12 @@ function pathMatches(entryPath: string, target: string, cwd: string): boolean {
38
39
  const a = normalizeContextPath(entryPath, cwd);
39
40
  const b = normalizeContextPath(target, cwd);
40
41
  if (a === b) return true;
41
- // suffix match for namespaced entries
42
- return entryPath.endsWith("/" + target) || entryPath.endsWith(target);
42
+ // Namespaced entries (`ctx/<id>/…`) must match on the FULL remainder after the
43
+ // namespace — never a bare suffix: "/src/a.ts" also ends "ctx/A/other/src/a.ts",
44
+ // which would refresh an unrelated deeper file (wrong-entry overwrite). The prefix
45
+ // regex itself is owned by namespace.ts (single matcher, DRY).
46
+ const ns = ctxPrefixOf(entryPath);
47
+ return ns !== undefined && entryPath.slice(ns.length) === b;
43
48
  }
44
49
 
45
50
  /**
@@ -72,8 +77,12 @@ export function upsertContextFile(
72
77
  typeof (item).path === "string" &&
73
78
  pathMatches((item as { path: string }).path, path, cwd)
74
79
  ) {
75
- next[n++] = entry;
76
- replaced = true;
80
+ // First match replaces; later duplicates of the same logical file are absorbed,
81
+ // otherwise two matching payload entries would emit the replacement twice.
82
+ if (!replaced) {
83
+ next[n++] = entry;
84
+ replaced = true;
85
+ }
77
86
  } else if (
78
87
  item !== null &&
79
88
  typeof item === "object" &&
@@ -123,15 +132,27 @@ _tokens = ${tokens}
123
132
  _old = context if isinstance(context, list) else []
124
133
  _next = []
125
134
  _found = False
135
+ def _ns_matches(p, t):
136
+ # Anchored namespace match, mirrors host ctxPrefixOf: full remainder after
137
+ # the ctx/<id>/ prefix must EQUAL the target — suffix endswith also hits
138
+ # deeper paths like ctx/A/other/src/a.ts for target src/a.ts (wrong-entry).
139
+ if not p.startswith("ctx/"):
140
+ return False
141
+ rest = p[4:]
142
+ i = rest.find("/")
143
+ if i < 0:
144
+ return False
145
+ return rest[i + 1:] == t
126
146
  for _e in _old:
127
147
  if isinstance(_e, dict) and str(_e.get("path", "")) in (_path, _path.replace("\\\\", "/")):
128
- _next.append({"path": _path, "content": _content, "tokens": _tokens})
129
- _found = True
130
- elif isinstance(_e, dict) and (
131
- str(_e.get("path", "")).endswith("/" + _path) or str(_e.get("path", "")).endswith(_path)
132
- ):
133
- _next.append({"path": str(_e.get("path")), "content": _content, "tokens": _tokens})
134
- _found = True
148
+ if not _found:
149
+ _next.append({"path": _path, "content": _content, "tokens": _tokens})
150
+ _found = True
151
+ elif isinstance(_e, dict) and _ns_matches(str(_e.get("path", "")), _path):
152
+ # First match replaces; later duplicates are absorbed (dedup mirrors host upsert).
153
+ if not _found:
154
+ _next.append({"path": str(_e.get("path")), "content": _content, "tokens": _tokens})
155
+ _found = True
135
156
  else:
136
157
  _next.append(_e)
137
158
  if not _found:
@@ -21,6 +21,21 @@ export function latestAnswerContentOf(results: readonly ReplResult[]): string |
21
21
  return null;
22
22
  }
23
23
 
24
+ /** Cap for the recovered-stdout fallback (P2 §3.4) — it rides `RlmResult`, not history. */
25
+ const LAST_STDOUT_CAP = 4_000;
26
+
27
+ /** Last non-empty stdout across a turn's blocks, capped. P2 §3.4: a run that ends without an
28
+ * `answer[...]` frame still printed its winning value, and re-running the whole task to get it
29
+ * is a waste (and non-deterministic). The bench recovers from here and marks the row
30
+ * `recovered: true`; the engine itself never treats stdout as an answer. */
31
+ export function latestStdoutOf(results: readonly ReplResult[]): string {
32
+ for (let i = results.length - 1; i >= 0; i--) {
33
+ const out = results[i]?.stdout.trim();
34
+ if (out) return out.length > LAST_STDOUT_CAP ? out.slice(-LAST_STDOUT_CAP) : out;
35
+ }
36
+ return "";
37
+ }
38
+
24
39
  /** True if any block in the turn raised an exception. Plain stderr does not count. */
25
40
  export function turnHadError(results: readonly ReplResult[]): boolean {
26
41
  return results.some((r) => r.raised);
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Token budget cascade (port of the v4/v5 `budget.py` engine).
3
3
  *
4
- * The budget is the PRIMARY run-length control: above COMPACTION_CEILING_TOKENS the cap is
4
+ * The budget is an OUTLIER CEILING, not a progress control (progress = maxIterations):
5
+ * windows at/below COMPACTION_CEILING_TOKENS are never metered; above it the cap is
5
6
  * max(ceiling, budgetShare × model context window) — the share can only stretch the working
6
- * budget further out, never cut under the ceiling;
7
- * one soft wrap-up turn at `softFrac` of the cap, and at the hard cap a deterministic
8
- * handoff (`distillTrajectory`) is handed to a fresh continuation run — chain-capped at
9
- * `maxContinuations`. Wall-clock timeouts stay only as hang backstops.
7
+ * budget further out, never cut under the ceiling. One soft wrap-up turn at `softFrac` of
8
+ * the cap, and at the hard cap a deterministic handoff (`distillTrajectory`) is handed to
9
+ * a fresh continuation run — chain-capped at `maxContinuations`. Wall-clock timeouts stay
10
+ * only as hang backstops.
10
11
  *
11
12
  * v5 counts the whole tree (root turns + sub-LLM usage) against the cap; the engine feeds
12
13
  * the run's LimitGuard totals in via `observeTotal` after every turn. Each continuation
@@ -30,22 +31,25 @@ type BudgetState = "" | "soft" | "hard";
30
31
 
31
32
  /** v5 verbatim: the soft wrap-up note prepended to the single turn after crossing soft. */
32
33
  export const WRAP_UP_BUDGET: string =
33
- "[budget] ~80% of your token cap — ONE turn left. If the task is answerable NOW, finalize " +
34
- '(set answer["ready"] = True). Otherwise print a compact findings dump: what is confirmed, ' +
35
- "current file/line or search position, and the exact next step — a fresh continuation picks " +
36
- "it up. Do not start new exploration.";
34
+ "[budget] ~80% of this run's outlier cap — ONE turn left. If the task is answerable NOW, " +
35
+ 'finalize (set answer["ready"] = True). Otherwise print a compact findings dump IN THE ' +
36
+ "NOTES: what is confirmed, current file/line or search position, and the exact next " +
37
+ "step — a continuation picks it up. The task, the packed context and the ledger carry " +
38
+ "over; only repl variables are re-derived. Do not start new exploration.";
37
39
 
38
40
  export const DEFAULT_NEXT_STEP: string =
39
41
  "continue the probing that was in flight, then finalize";
40
42
 
41
43
  /** v5 verbatim template (adapting the finalize spelling to this plugin's REPL). */
42
44
  const HANDOFF_TEMPLATE: string =
43
- "A prior RLM run hit its token cap mid-task.\n" +
45
+ "A prior RLM run hit its outlier token ceiling mid-task.\n" +
44
46
  "You are its continuation — pick up EXACTLY where it stopped.\n\n" +
45
47
  "ORIGINAL TASK:\n{query}\n\n" +
46
48
  "CONFIRMED FINDINGS SO FAR:\n{findings}\n\n" +
47
49
  "CURRENT STATE / LAST ACTIONS:\n{state}\n\n" +
48
50
  "NEXT STEP: {next}\n" +
51
+ "NOTE: repl variables are re-derived, but the task, the packed context and the ledger " +
52
+ "carry over. `add_context()` the same external sources again if you still need them.\n" +
49
53
  "Do not re-do confirmed work; continue from the NEXT STEP and finalize as\n" +
50
54
  'soon as the task is answerable (answer["ready"] = True).';
51
55
 
@@ -137,10 +141,14 @@ function unboundedBudget(config: RlmConfig): TokenBudget {
137
141
 
138
142
  export function resolveBudget(contextWindow: number | undefined, config: RlmConfig): TokenBudget {
139
143
  const ctx = contextWindow !== undefined && contextWindow > 0 ? contextWindow : 32_000;
140
- if (ctx <= COMPACTION_CEILING_TOKENS) return unboundedBudget(config);
141
144
  // The share only stretches the budget BEYOND the absolute ceiling — never under it.
142
145
  const shareCap = Math.max(COMPACTION_CEILING_TOKENS, Math.floor(ctx * config.budgetShare));
143
146
  const cap = config.budgetTaskCap > 0 ? Math.min(shareCap, config.budgetTaskCap) : shareCap;
147
+ // LO rule (2025-09-10): small windows are never budget-amputated — but an EXPLICIT
148
+ // budgetTaskCap that actually binds (below shareCap) must still be honored. The old
149
+ // early-return swallowed the explicit cap on windows ≤ the ceiling (task-cap bug).
150
+ const userCapped = config.budgetTaskCap > 0 && config.budgetTaskCap < shareCap;
151
+ if (!userCapped && ctx <= COMPACTION_CEILING_TOKENS) return unboundedBudget(config);
144
152
  return makeBudget(config, Math.max(cap, 1));
145
153
  }
146
154
 
@@ -150,17 +158,27 @@ export function resolveBudget(contextWindow: number | undefined, config: RlmConf
150
158
  */
151
159
  export function truncateMid(text: string, maxChars: number): string {
152
160
  if (text.length <= maxChars) return text;
153
- const half = Math.max(0, maxChars - ELISION_MARK.length) >> 1;
161
+ // Reserve for the WORST-CASE rendered mark, not the shortest (FINDING-5): the elision
162
+ // count is substituted at render time, so a 2+ digit count grows the mark past the
163
+ // length `half` was budgeted from. digits(text.length) upper-bounds digits(elided) —
164
+ // one pass, and the output can never exceed maxChars.
165
+ const reserve = maxChars - (ELISION_MARK.length - 1 + String(text.length).length);
166
+ if (reserve <= 0) {
167
+ // Cap smaller than even a mark-only render: head-truncate to keep the exact
168
+ // ≤ maxChars guarantee instead of emitting an oversized degenerate mark.
169
+ return text.slice(0, maxChars);
170
+ }
171
+ const half = reserve >> 1;
154
172
  const elided = text.length - (half * 2);
155
173
  return text.slice(0, half) + ELISION_MARK.replace("N", String(elided)) + text.slice(text.length - half);
156
174
  }
157
175
 
158
176
  /** Digest/handoff section caps — ONE source: budget.ts's handoff distillation and the root
159
177
  * digest (core/root-digest.ts) must never drift apart on the same trajectory heuristics. */
160
- export const FINDINGS_MAX = 6;
178
+ export const FINDINGS_MAX = 12; // aligns with RUN_STATE_LIMITS.findings — Σ and handoff agree
161
179
  export const FINDINGS_MIN_CHARS = 20;
162
180
  export const STATE_MAX = 8;
163
- const QUERY_CHARS = 800;
181
+ const QUERY_CHARS = 4_000; // full task statement fits; 800 forced the model to "forget" its own goal
164
182
  const STATE_NEEDLE = "REPL stdout";
165
183
  /** Next-step probe shared by the engine handoff and the root digest (one wording source). */
166
184
  export const NEXT_STEP_RE = /next|then|will |todo/i;