@hicaru/pi-rlm 0.3.9 → 0.3.13

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
@@ -156,8 +156,7 @@ on `poolside/laguna-xs-2.1:free` (a free ~32B model):
156
156
  | Coding (retry fix) | orchestrator | **correct** (file edited) |
157
157
  | Live smoke needle | classic RLM | **hit** (~5k tokens) |
158
158
 
159
- > On a *free* model. Frontier models do even better. See `rlm_test/RESULTS_AGENT.md`
160
- > and `rlm_test/RESULTS.md` for full methodology.
159
+ > On a *free* model. Frontier models do even better.
161
160
 
162
161
  ## Security
163
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.3.9",
3
+ "version": "0.3.13",
4
4
  "author": "hicaru",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,6 +12,7 @@ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
12
12
  import { type ChatMsg, modelComplete } from "../model.ts";
13
13
  import { checkResourceLimits } from "../../core/resource-limits.ts";
14
14
  import { errorMessage, formatError } from "../../util/errors.ts";
15
+ import { retryPolicy } from "../../util/retry.ts";
15
16
  import type { Semaphore } from "../../util/concurrency.ts";
16
17
  import type { Invocation, SubcallConfig } from "./types.ts";
17
18
 
@@ -33,6 +34,7 @@ export async function complete1(
33
34
  prompt: string,
34
35
  track: (usage: Usage) => void,
35
36
  deps: Complete1Deps,
37
+ hooks?: { readonly onThrottlePark?: (ms: number) => void; readonly onThrottleRelease?: () => void },
36
38
  ): Promise<string> {
37
39
  const config = deps.getConfig();
38
40
  const limitError = checkResourceLimits({
@@ -55,6 +57,9 @@ export async function complete1(
55
57
  maxTokens: config.subSampling?.maxTokens,
56
58
  temperature: config.subSampling?.temperature,
57
59
  reasoning: config.subSampling?.reasoning,
60
+ retry: retryPolicy(config),
61
+ onThrottlePark: hooks?.onThrottlePark,
62
+ onThrottleRelease: hooks?.onThrottleRelease,
58
63
  signal: deps.signal,
59
64
  }),
60
65
  );
@@ -8,6 +8,7 @@
8
8
  import type { Usage } from "@earendil-works/pi-ai";
9
9
  import { isErrorText } from "../../util/errors.ts";
10
10
  import { previewText } from "../../text/preview.ts";
11
+ import type { SubcallPhase } from "../../tool/rlm-details.ts";
11
12
  import type { Invocation } from "./types.ts";
12
13
 
13
14
  export interface EmitOpts {
@@ -24,14 +25,19 @@ export interface EmitSummary {
24
25
  readonly total?: number;
25
26
  }
26
27
 
28
+ /** Targeted update for THIS node — the 2nd arg every `fn` handed to emitting() receives. */
29
+ export type EmitNote = (u: { readonly phase?: SubcallPhase; readonly detail?: string }) => void;
30
+
27
31
  /**
28
32
  * Create a subcall node, run `fn`, then update the node with status/cost/preview.
29
33
  * `fn` should not throw for soft failures (prefer Error: strings). Hard throws mark error.
34
+ * The `note` arg lets `fn` surface live per-node state — e.g. parking on the rate-limit
35
+ * cooldown (throttleHooks below).
30
36
  */
31
37
  export async function emitting<T>(
32
38
  inv: Invocation,
33
39
  opts: EmitOpts,
34
- fn: (track: (usage: Usage) => void) => Promise<T>,
40
+ fn: (track: (usage: Usage) => void, note: EmitNote) => Promise<T>,
35
41
  summarize: (out: T) => EmitSummary,
36
42
  ): Promise<T> {
37
43
  const id = inv.emitter.emitSubcallCreated({
@@ -44,6 +50,9 @@ export async function emitting<T>(
44
50
  });
45
51
  // Leaf nodes spend their whole lifetime waiting on the model — say so from birth.
46
52
  inv.emitter.emitSubcallUpdated({ id, phase: "waiting" });
53
+ const note: EmitNote = (u): void => {
54
+ inv.emitter.emitSubcallUpdated({ id, ...u });
55
+ };
47
56
 
48
57
  let costUsd = 0;
49
58
  let tokens = 0;
@@ -53,7 +62,7 @@ export async function emitting<T>(
53
62
  };
54
63
 
55
64
  try {
56
- const out = await fn(track);
65
+ const out = await fn(track, note);
57
66
  const summary = summarize(out);
58
67
  inv.emitter.emitSubcallUpdated({
59
68
  id,
@@ -84,3 +93,22 @@ export async function emitting<T>(
84
93
  export function summarizeLeaf(out: string): EmitSummary {
85
94
  return { preview: previewText(out), error: isErrorText(out) ? out : undefined };
86
95
  }
96
+
97
+ /**
98
+ * Wire a leaf's `note` into modelComplete's throttle callbacks: parked → "queued" with the
99
+ * pending seconds in detail; released → back to plain "waiting". One helper so every leaf
100
+ * call site reports the queue state identically.
101
+ */
102
+ export function throttleHooks(note: EmitNote): {
103
+ readonly onThrottlePark: (ms: number) => void;
104
+ readonly onThrottleRelease: () => void;
105
+ } {
106
+ return {
107
+ onThrottlePark: (ms): void => {
108
+ note({ phase: "queued", detail: `rate limit — waiting ${Math.max(1, Math.round(ms / 1000))}s` });
109
+ },
110
+ onThrottleRelease: (): void => {
111
+ note({ phase: "waiting" });
112
+ },
113
+ };
114
+ }
@@ -5,7 +5,7 @@
5
5
  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
- import { emitting, summarizeLeaf } from "./emitting.ts";
8
+ import { emitting, summarizeLeaf, throttleHooks } from "./emitting.ts";
9
9
  import { formatError, errorMessage } from "../../util/errors.ts";
10
10
  import { previewText } from "../../text/preview.ts";
11
11
  import type { SpawnResult, SubcallHandlerDeps } from "./types.ts";
@@ -106,7 +106,7 @@ export function createLlmQueryHandler(
106
106
  args: `prompt: ${previewText(prompt)}`,
107
107
  model: displayModel(deps),
108
108
  },
109
- (track: (u: Usage) => void) => complete1(inv, prompt, track, cdeps),
109
+ (track: (u: Usage) => void, note) => complete1(inv, prompt, track, cdeps, throttleHooks(note)),
110
110
  summarizeLeaf,
111
111
  );
112
112
  // v5 TaskLedger for leaves: identical prompts coalesce onto one completion (key has no
@@ -166,13 +166,13 @@ export function createLlmBatchHandler(
166
166
  // NO outer gate — complete1 takes the single leaf slot per prompt.
167
167
  // v5 (audit H3): every item routes through the ledger — duplicate prompts inside
168
168
  // one batch (or twins of other in-flight leaves) coalesce instead of paying N times.
169
- (track: (u: Usage) => void) =>
169
+ (track: (u: Usage) => void, note) =>
170
170
  runClaimedLeaf(
171
171
  ledger,
172
172
  ledger === undefined ? undefined : leafClaimKey(deps, p),
173
173
  p,
174
174
  inv.depth,
175
- () => complete1(inv, p, track, cdeps),
175
+ () => complete1(inv, p, track, cdeps, throttleHooks(note)),
176
176
  ),
177
177
  summarizeLeaf,
178
178
  ),
@@ -15,7 +15,7 @@ import type { Invocation, SpawnResult, SubcallHandlerDeps } from "./types.ts";
15
15
  import type { SubcallOpts } from "../../sandbox/interrupts.ts";
16
16
  import { SPAWN_HINT, spawnAndRun, type SpawnDeps } from "./task-registry.ts";
17
17
  import { complete1, type Complete1Deps } from "./completion.ts";
18
- import { emitting, summarizeLeaf } from "./emitting.ts";
18
+ import { emitting, summarizeLeaf, throttleHooks } from "./emitting.ts";
19
19
  import { leafClaimKey, runClaimedLeaf } from "./llm-query.ts";
20
20
 
21
21
  const UNWIRED = formatError("RLM bridge not wired for this invocation");
@@ -265,7 +265,7 @@ export function createRlmQueryHandler(deps: SubcallHandlerDeps, sd: SpawnDeps) {
265
265
  label: "rlm_query→llm (demoted)",
266
266
  args: previewText(task),
267
267
  },
268
- (track) => complete1(inv, task, track, completeDeps(deps)),
268
+ (track, note) => complete1(inv, task, track, completeDeps(deps), throttleHooks(note)),
269
269
  summarizeLeaf,
270
270
  ),
271
271
  ),
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Every subcall (llm_query, llm_batch, rlm_query, rlm_batch) returns a SpawnResult
5
5
  * immediately with a task_id. The model must call await(task_id) to collect the
6
- * real answer. This contract is proven in rlm_test (api_v5 + batch, scores 0.89–1.0).
6
+ * real answer. This contract is proven in bake-off runs (api_v5 + batch, scores 0.89–1.0).
7
7
  */
8
8
 
9
9
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
@@ -93,6 +93,13 @@ export interface SubcallConfig {
93
93
  readonly rlmBudget?: number;
94
94
  /** v5 durable memory gates (optional; omitted → memory off). */
95
95
  readonly enableMemory?: boolean;
96
+ /** v5.1 retry knobs — structural slice of RlmConfig so retryPolicy() can read them. */
97
+ readonly retryMaxAttempts?: number;
98
+ readonly rateLimitMaxAttempts?: number;
99
+ readonly retryBaseDelayMs?: number;
100
+ readonly retryMaxDelayMs?: number;
101
+ readonly throttleBaseMs?: number;
102
+ readonly throttleMaxMs?: number;
96
103
  }
97
104
 
98
105
  export interface SubcallHandlerDeps {
@@ -2,12 +2,16 @@
2
2
  * modelComplete — a single, serverless, in-process LLM completion.
3
3
  *
4
4
  * This is the one place that talks to a provider. It resolves the API key from pi's
5
- * ModelRegistry (keys live here, never in the sandbox) and calls pi-ai's `completeSimple`.
6
- * Used both for `llm_query` (one user prompt) and for the headless RLM root (full history).
5
+ * ModelRegistry (keys live here, never in the sandbox) and calls pi-ai's `completeSimple`,
6
+ * wrapped in completeWithRetry: transient 429/5xx get exponential backoff (honoring
7
+ * `retry-after` via the onResponse hook) and rate limits additionally cool the shared
8
+ * per-provider throttle. Used both for `llm_query` (one user prompt) and for the headless
9
+ * RLM root (full history).
7
10
  */
8
11
 
9
12
  import { type Api, completeSimple, type Message, type Model, type ThinkingLevel, type Usage } from "@earendil-works/pi-ai/compat";
10
13
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
14
+ import { completeWithRetry, DEFAULT_RETRY_POLICY, type RetryPolicy } from "../util/retry.ts";
11
15
 
12
16
  export type Role = "system" | "user" | "assistant";
13
17
  export interface ChatMsg {
@@ -23,6 +27,11 @@ export interface CompleteOptions {
23
27
  readonly temperature?: number;
24
28
  readonly reasoning?: ThinkingLevel;
25
29
  readonly signal?: AbortSignal;
30
+ /** Retry + adaptive throttle for transient 429/5xx; defaults apply when omitted. */
31
+ readonly retry?: RetryPolicy;
32
+ /** v5.1 UX: fired while parked on the rate-limit cooldown ("queued") / when released. */
33
+ readonly onThrottlePark?: (ms: number) => void;
34
+ readonly onThrottleRelease?: () => void;
26
35
  }
27
36
 
28
37
  export interface CompleteResult {
@@ -78,20 +87,29 @@ export async function modelComplete(messages: readonly ChatMsg[], opts: Complete
78
87
  : opts.system
79
88
  : built.systemPrompt;
80
89
 
81
- const msg = await completeSimple(
82
- opts.model,
83
- { systemPrompt, messages: built.messages },
84
- {
85
- apiKey: auth.apiKey,
86
- headers: auth.headers,
87
- maxTokens: opts.maxTokens,
88
- temperature: opts.temperature,
89
- reasoning: opts.reasoning,
90
- signal: opts.signal,
90
+ const msg = await completeWithRetry(
91
+ async (note) => {
92
+ const response = await completeSimple(
93
+ opts.model,
94
+ { systemPrompt, messages: built.messages },
95
+ {
96
+ apiKey: auth.apiKey,
97
+ headers: auth.headers,
98
+ maxTokens: opts.maxTokens,
99
+ temperature: opts.temperature,
100
+ reasoning: opts.reasoning,
101
+ signal: opts.signal,
102
+ onResponse: (res) => { note(res.status, res.headers); },
103
+ },
104
+ );
105
+ // pi-ai folds provider failures into the message: "error"/"aborted" + errorMessage.
106
+ // Throwing here puts every completion failure onto ONE path — the retry classifier.
107
+ if (response.stopReason === "error" || response.stopReason === "aborted") {
108
+ throw new Error(response.errorMessage ?? response.stopReason);
109
+ }
110
+ return response;
91
111
  },
112
+ { policy: opts.retry ?? DEFAULT_RETRY_POLICY, provider: opts.model.provider, signal: opts.signal, onPark: opts.onThrottlePark, onRelease: opts.onThrottleRelease },
92
113
  );
93
- if (msg.stopReason === "error" || msg.stopReason === "aborted") {
94
- throw new Error(msg.errorMessage ?? msg.stopReason);
95
- }
96
114
  return { text: extractText(msg.content), usage: msg.usage };
97
115
  }
@@ -13,12 +13,22 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
13
13
  execTimeoutS: 120,
14
14
  requestTimeoutMs: 15 * 60_000,
15
15
  // Session-wide, not per-batch: spawn() puts many requests on the wire at once, so this is
16
- // the only thing bounding leaf fan-out.
17
- maxConcurrentSubcalls: 16,
16
+ // the only thing bounding leaf fan-out. Default 8 — a sane rate for per-account limits
17
+ // (raise to 16/32 via /rlm-config when the provider allows).
18
+ maxConcurrentSubcalls: 8,
18
19
  // Children are bounded separately and lower: each is a Python subprocess holding its own copy
19
20
  // of the context it inherited, where a leaf is one HTTP request. Worst case is
20
- // (maxDepth - 1) × this many concurrent child engines.
21
- maxConcurrentChildren: 6,
21
+ // (maxDepth - 1) × this many concurrent child engines. Default 4.
22
+ maxConcurrentChildren: 4,
23
+ // v5.1 rate-limit resilience (util/retry.ts): 3 total attempts, 500ms→15s backoff,
24
+ // 2s→60s adaptive per-provider cooldown. All overridable in rlm.json.
25
+ retryMaxAttempts: 3,
26
+ // 429s park on the cooldown instead of dying — up to 8 windows (2s→4s→…≤60s ≈ 4 min).
27
+ rateLimitMaxAttempts: 8,
28
+ retryBaseDelayMs: 500,
29
+ retryMaxDelayMs: 15_000,
30
+ throttleBaseMs: 2_000,
31
+ throttleMaxMs: 60_000,
22
32
  maxPromptChars: 400_000,
23
33
  maxErrors: 5,
24
34
  orchestrator: true,
@@ -68,6 +68,19 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
68
68
  if (maxConcurrentSubcalls !== undefined) out.maxConcurrentSubcalls = maxConcurrentSubcalls;
69
69
  const maxConcurrentChildren = validateNumber(r.maxConcurrentChildren, 1);
70
70
  if (maxConcurrentChildren !== undefined) out.maxConcurrentChildren = maxConcurrentChildren;
71
+ // v5.1 rate-limit resilience knobs
72
+ const retryMaxAttempts = validateNumber(r.retryMaxAttempts, 1);
73
+ if (retryMaxAttempts !== undefined) out.retryMaxAttempts = retryMaxAttempts;
74
+ const rateLimitMaxAttempts = validateNumber(r.rateLimitMaxAttempts, 1);
75
+ if (rateLimitMaxAttempts !== undefined) out.rateLimitMaxAttempts = rateLimitMaxAttempts;
76
+ const retryBaseDelayMs = validateNumber(r.retryBaseDelayMs, 0);
77
+ if (retryBaseDelayMs !== undefined) out.retryBaseDelayMs = retryBaseDelayMs;
78
+ const retryMaxDelayMs = validateNumber(r.retryMaxDelayMs, 100);
79
+ if (retryMaxDelayMs !== undefined) out.retryMaxDelayMs = retryMaxDelayMs;
80
+ const throttleBaseMs = validateNumber(r.throttleBaseMs, 0);
81
+ if (throttleBaseMs !== undefined) out.throttleBaseMs = throttleBaseMs;
82
+ const throttleMaxMs = validateNumber(r.throttleMaxMs, 100);
83
+ if (throttleMaxMs !== undefined) out.throttleMaxMs = throttleMaxMs;
71
84
  const maxPromptChars = validateNumber(r.maxPromptChars, 1000);
72
85
  if (maxPromptChars !== undefined) out.maxPromptChars = maxPromptChars;
73
86
  const maxTimeoutMs = validateNumber(r.maxTimeoutMs, 1000);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Token budget cascade (port of rlm_test v4/v5 `budget.py`).
2
+ * Token budget cascade (port of the v4/v5 `budget.py` engine).
3
3
  *
4
4
  * The budget is the PRIMARY run-length control: cap = budgetShare × model context window,
5
5
  * one soft wrap-up turn at `softFrac` of the cap, and at the hard cap a deterministic
@@ -9,6 +9,7 @@
9
9
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
10
10
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
11
11
  import { type ChatMsg, modelComplete } from "../bridge/model.ts";
12
+ import type { RetryPolicy } from "../util/retry.ts";
12
13
  import { estimateMessageTokens } from "../text/tokens.ts";
13
14
 
14
15
  const DEFAULT_CONTEXT_WINDOW = 128_000;
@@ -24,6 +25,8 @@ export interface CompactionDeps {
24
25
  readonly contextWindow?: number;
25
26
  readonly thresholdPct?: number;
26
27
  readonly signal?: AbortSignal;
28
+ /** v5.1 retry policy for modelComplete; defaults apply when omitted. */
29
+ readonly retry?: RetryPolicy;
27
30
  }
28
31
 
29
32
  /** True if the history is at/over the compaction threshold. */
@@ -93,6 +96,7 @@ export async function compactHistory(
93
96
  model: deps.model,
94
97
  registry: deps.registry,
95
98
  signal: deps.signal,
99
+ retry: deps.retry,
96
100
  });
97
101
  onUsage?.(usage);
98
102
  const system = history.find((m) => m.role === "system");
@@ -29,6 +29,7 @@ import { previewStdout, previewText } from "../text/preview.ts";
29
29
  import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
30
30
  import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
31
31
  import { compactHistory, elideOldToolPayloads, shouldCompact } from "./compaction.ts";
32
+ import { retryPolicy } from "../util/retry.ts";
32
33
  import { appendUserMessage } from "./history.ts";
33
34
  import { runTurn } from "./iteration.ts";
34
35
  import { type Limits, LimitError, LimitGuard } from "./limits.ts";
@@ -138,7 +139,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
138
139
  if (deps.config.enableLedger) runLedger.beginRun(input.rootPrompt);
139
140
 
140
141
  // v5 durable memory: read-only root replay — an identical prompt over an identical
141
- // context answers for zero API calls (measured 10,051 → 0 tok in rlm_test).
142
+ // context answers for zero API calls (measured 10,051 → 0 tok in bake-off runs).
142
143
  const rootMemory =
143
144
  deps.memory !== undefined && deps.config.enableMemory ? deps.memory : undefined;
144
145
  const modelRefStr = `${model.provider}/${model.id}`;
@@ -329,6 +330,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
329
330
  registry: deps.registry,
330
331
  contextWindow: model.contextWindow,
331
332
  thresholdPct: deps.config.compactionThresholdPct,
333
+ retry: retryPolicy(deps.config),
332
334
  signal: deps.signal,
333
335
  };
334
336
  if (shouldCompact(history, compactionDeps)) {
@@ -341,7 +343,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
341
343
  pendingReplOutputs = undefined;
342
344
  }
343
345
 
344
- // Soft runtime nudge (rlm_test parity): remind the model to await pending host tasks.
346
+ // Soft runtime nudge (engine parity): remind the model to await pending host tasks.
345
347
  const pendingIds = taskRegistry.awaitDeps.unawaitedIds();
346
348
  if (pendingIds.length > 0) {
347
349
  appendUserMessage(
@@ -372,6 +374,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
372
374
  model: model,
373
375
  registry: deps.registry,
374
376
  sampling: rootSampling,
377
+ retry: deps.complete === undefined ? retryPolicy(deps.config) : undefined,
375
378
  signal: deps.signal,
376
379
  complete: deps.complete,
377
380
  onPhase: reportPhase,
@@ -7,6 +7,7 @@
7
7
  import type { Api, Model, Usage } from "@earendil-works/pi-ai";
8
8
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
9
9
  import { type ChatMsg, type CompleteOptions, type CompleteResult, modelComplete } from "../bridge/model.ts";
10
+ import type { RetryPolicy } from "../util/retry.ts";
10
11
  import type { ReplResult } from "../sandbox/protocol.ts";
11
12
  import type { PythonSandbox } from "../sandbox/sandbox.ts";
12
13
  import { findReplBlocks } from "../text/parsing.ts";
@@ -31,6 +32,8 @@ export interface TurnDeps {
31
32
  readonly signal?: AbortSignal;
32
33
  /** Test-only override for model completion (scripted responses). */
33
34
  readonly complete?: CompleteFn;
35
+ /** v5.1 retry policy for modelComplete (rate-limit resilience); defaults apply when omitted. */
36
+ readonly retry?: RetryPolicy;
34
37
  /** Live activity reporting for the tree UI (thinking → repl/texting per turn). */
35
38
  readonly onPhase?: (phase: SubcallPhase) => void;
36
39
  }
@@ -44,6 +47,10 @@ export async function runTurn(history: readonly ChatMsg[], sandbox: PythonSandbo
44
47
  maxTokens: deps.sampling?.maxTokens,
45
48
  temperature: deps.sampling?.temperature,
46
49
  reasoning: deps.sampling?.reasoning,
50
+ retry: deps.retry,
51
+ // Rate-limit parking is a visible phase too — the root/child spinner says "queued".
52
+ onThrottlePark: deps.onPhase ? () => deps.onPhase?.("queued") : undefined,
53
+ onThrottleRelease: deps.onPhase ? () => deps.onPhase?.("thinking") : undefined,
47
54
  signal: deps.signal,
48
55
  });
49
56
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * TaskLedger — the session blackboard (port of rlm_test v5 `ledger.py`).
2
+ * TaskLedger — the session blackboard (port of the v5 `ledger.py` engine).
3
3
  *
4
4
  * One instance per root run (engine) or per session (native repl tool), threaded down to every
5
5
  * child through `SubcallHandlerDeps.ledger` / `RlmInput.ledger` — the same seam as
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Durable memory (port of rlm_test v5 `memory/store.py`).
2
+ * Durable memory (port of the v5 `memory/store.py` engine).
3
3
  *
4
4
  * L1 episodes: content-addressed replay — a recorded child/root answer replays for ZERO
5
5
  * API calls while every file it touched still hashes to the recorded sha256.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Model context-window registry (port of rlm_test v4/v5 `models.py`).
2
+ * Model context-window registry (port of the v4/v5 `models.py` engine).
3
3
  *
4
4
  * The plugin already knows context windows from model metadata (`Model.contextWindow`); this
5
5
  * registry is the offline fallback for models whose metadata carries none: a conservative
package/src/core/types.ts CHANGED
@@ -25,6 +25,20 @@ export interface RlmConfig {
25
25
  /** Concurrent recursive child engines admitted per depth. Lower than maxConcurrentSubcalls:
26
26
  * each child is a Python subprocess holding its own copy of the inherited context. */
27
27
  readonly maxConcurrentChildren: number;
28
+ /** v5.1 rate-limit resilience (see util/retry.ts): transient 429/5xx are retried with
29
+ * backoff, and rate limits additionally cool a shared per-provider throttle.
30
+ * retryMaxAttempts counts TOTAL attempts per call (1 = never retry).
31
+ * rateLimitMaxAttempts is the SEPARATE budget a 429 may burn while parking on the
32
+ * cooldown — generous, because "come back later" is a queue, not a failure. */
33
+ readonly retryMaxAttempts?: number;
34
+ readonly rateLimitMaxAttempts?: number;
35
+ readonly retryBaseDelayMs?: number;
36
+ /** Cap for any single retry delay, including a parsed `retry-after`. */
37
+ readonly retryMaxDelayMs?: number;
38
+ /** First cooldown when a provider 429s without timing; doubles per consecutive strike. */
39
+ readonly throttleBaseMs?: number;
40
+ /** Ceiling for the adaptive per-provider cooldown. */
41
+ readonly throttleMaxMs?: number;
28
42
  /** Reject sub-LLM prompts larger than this many chars. */
29
43
  readonly maxPromptChars: number;
30
44
  /** Max wall-clock ms across the whole tree before the engine stops (undefined = no cap). */
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ import { buildSessionGates, type SubcallGates } from "./util/concurrency.ts";
23
23
  import { BackgroundTasks } from "./tool/background-tasks.ts";
24
24
  import { MemoryStore } from "./core/memory.ts";
25
25
  import { modelComplete } from "./bridge/model.ts";
26
+ import { retryPolicy } from "./util/retry.ts";
26
27
  import { resolve } from "node:path";
27
28
  import { resolveSource } from "./context/resolve.ts";
28
29
  import { formatContextListing } from "./context/listing.ts";
@@ -256,7 +257,11 @@ export default function rlmExtension(pi: ExtensionAPI): void {
256
257
  // the workspace root is only known once the session starts.
257
258
  const consolidateModel = llmModel;
258
259
  memory.setLlm((prompt) =>
259
- modelComplete([{ role: "user", content: prompt }], { model: consolidateModel, registry: ctx.modelRegistry })
260
+ modelComplete([{ role: "user", content: prompt }], {
261
+ model: consolidateModel,
262
+ registry: ctx.modelRegistry,
263
+ retry: retryPolicy(controller.config),
264
+ })
260
265
  .then((r) => r.text));
261
266
  memory.setRoot(ctx.cwd ?? process.cwd());
262
267
  // v5 provider caps (audit C1/C6): ONE resolver shared by both composition roots — the
@@ -1,6 +1,6 @@
1
1
  /** Native-mode prompts — the main Pi agent drives the sandbox through the `repl` tool.
2
2
  *
3
- * Structure mirrors rlm_test api_v5_anthropic (best bake-off arm): role → contract → routing →
3
+ * Structure mirrors api_v5_anthropic (best bake-off arm): role → contract → routing →
4
4
  * few-shots → anti-patterns → REPL surface. Goal: multi-area work fires rlm_batch / rlm_query
5
5
  * as Task (BG), not serial repl+native read.
6
6
  */
@@ -9,8 +9,9 @@
9
9
  export type SubcallKind = "root" | "rlm" | "llm" | "batch" | "tool";
10
10
  export type SubcallStatus = "running" | "done" | "error";
11
11
 
12
- /** Live activity of a node while status is "running" — powers the tree/modal UI. */
13
- export type SubcallPhase = "thinking" | "texting" | "repl" | "waiting" | "spawning";
12
+ /** Live activity of a node while status is "running" — powers the tree/modal UI.
13
+ * "queued" = parked on the rate-limit cooldown (util/throttle.ts), not in flight. */
14
+ export type SubcallPhase = "thinking" | "texting" | "repl" | "waiting" | "spawning" | "queued";
14
15
  export type RlmRunStatus = "running" | "done" | "error" | "aborted";
15
16
 
16
17
  export interface RlmSubcall {
@@ -40,7 +40,8 @@ export interface NodeRow {
40
40
  readonly prefix: string;
41
41
  readonly expandable: boolean;
42
42
  readonly expanded: boolean;
43
- readonly icon: SubcallStatus;
43
+ /** SubcallStatus, or "queued" while the node parks on the rate-limit cooldown. */
44
+ readonly icon: SubcallStatus | "queued";
44
45
  readonly phase?: SubcallPhase;
45
46
  readonly label: string;
46
47
  /** The row's OWN token spend for its OWN model — never a subtree sum. */
@@ -63,7 +64,8 @@ export interface GroupRow {
63
64
  readonly model?: string;
64
65
  /** Sum over members — one model only (the group key pins it), so never a blend. */
65
66
  readonly tokens: number;
66
- readonly icon: SubcallStatus;
67
+ /** SubcallStatus, or "queued" while any member parks on the rate-limit cooldown. */
68
+ readonly icon: SubcallStatus | "queued";
67
69
  readonly expandable: boolean;
68
70
  readonly expanded: boolean;
69
71
  }
@@ -98,9 +100,12 @@ function partition(children: readonly RlmSubcall[], byParent: ReadonlyMap<string
98
100
  return out;
99
101
  }
100
102
 
101
- /** RlmRunStatus has "aborted"; the row icon set does not — aborted renders as error. */
102
- function iconOf(status: SubcallStatus | RlmRunStatus): SubcallStatus {
103
- return status === "aborted" ? "error" : status;
103
+ /** RlmRunStatus has "aborted"; the row icon set does not — aborted renders as error.
104
+ * A RUNNING node parked on the rate-limit cooldown renders as "queued" (◷). */
105
+ function iconOf(status: SubcallStatus | RlmRunStatus, phase: SubcallPhase | undefined): SubcallStatus | "queued" {
106
+ if (status === "aborted") return "error";
107
+ if (status === "running" && phase === "queued") return "queued";
108
+ return status;
104
109
  }
105
110
 
106
111
  /**
@@ -138,7 +143,7 @@ export function buildRows(
138
143
  prefix,
139
144
  expandable: children.length > 0,
140
145
  expanded,
141
- icon: iconOf(sc.status),
146
+ icon: iconOf(sc.status, sc.phase),
142
147
  phase: sc.phase,
143
148
  label: sc.label,
144
149
  tokens: sc.tokens,
@@ -187,7 +192,7 @@ export function buildRows(
187
192
  label: entry.label,
188
193
  model: entry.model,
189
194
  tokens,
190
- icon: iconOf(entry.status),
195
+ icon: iconOf(entry.status, entry.members.some((m) => m.phase === "queued") ? "queued" : undefined),
191
196
  expandable: true,
192
197
  expanded,
193
198
  });
@@ -209,7 +214,7 @@ export function buildRows(
209
214
  prefix: "",
210
215
  expandable: roots.length > 0,
211
216
  expanded: !collapsed.has(run.runId),
212
- icon: iconOf(run.status),
217
+ icon: iconOf(run.status, run.rootPhase),
213
218
  phase: run.rootPhase,
214
219
  label: run.rootLabel,
215
220
  tokens: run.rootTokens,
@@ -11,7 +11,7 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
11
  import { formatTokens, spinnerFrame } from "../theme.ts";
12
12
  import type { GroupRow, NodeRow, TreeRow } from "./tree-model.ts";
13
13
 
14
- const GLYPHS = Object.freeze({ done: "✓", error: "✗", expanded: "▾", collapsed: "▸", leaf: " " } as const);
14
+ const GLYPHS = Object.freeze({ done: "✓", error: "✗", queued: "◷", expanded: "▾", collapsed: "▸", leaf: " " } as const);
15
15
  const MODEL_MAX = 14;
16
16
 
17
17
  /** "openai/gpt-5-mini" → "gpt-5-mini", hard-capped so rows stay on one line. */
@@ -24,6 +24,7 @@ function iconGlyph(row: NodeRow, theme: Theme): string {
24
24
  switch (row.icon) {
25
25
  case "done": return theme.fg("success", GLYPHS.done);
26
26
  case "error": return theme.fg("error", GLYPHS.error);
27
+ case "queued": return theme.fg("warning", GLYPHS.queued);
27
28
  default: return theme.fg("warning", spinnerFrame());
28
29
  }
29
30
  }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Retry + rate-limit classification for LLM completions — the retry half of resilience.
3
+ *
4
+ * pi-ai surfaces provider failures as `stopReason:"error"` plus an errorMessage STRING
5
+ * (the HTTP status baked into the text, e.g. `429: {"code":"1302",...}`), but its
6
+ * `onResponse` hook still hands us the raw `{status, headers}` of every HTTP response.
7
+ * Classification therefore runs on both: captured status first, text patterns as the
8
+ * fallback — the same shape pi-ai's own codex provider uses internally. Timing, when the
9
+ * provider sends it, comes from `retry-after` / `retry-after-ms` headers (numeric seconds,
10
+ * milliseconds, or an HTTP-date); otherwise exponential backoff with jitter, capped.
11
+ *
12
+ * Rate-limit errors additionally penalize the per-provider cooldown (util/throttle.ts):
13
+ * the retry sleeps, and every OTHER request queued behind the same provider waits too.
14
+ */
15
+
16
+ import { ProviderCooldown, sleepMs, sharedCooldown } from "./throttle.ts";
17
+
18
+ // Auth/quota failures must not be retried — they burn attempts and never recover.
19
+ const NON_RETRYABLE_TEXT =
20
+ /api[ -]?key|unauthorized|forbidden|permission denied|billing|insufficient|balance|quota exceeded|not.?found|context length|too large|invalid request|malformed/i;
21
+ // Transport/server transients — worth another attempt.
22
+ const RETRYABLE_TEXT =
23
+ /\b429\b|rate.?limit|overloaded|service.?unavailable|upstream|timeout|timed.?out|temporarily|try.?again|econnreset|econnrefused|etimedout|socket hang up|network|1302|速率|频率/i;
24
+ const RATE_LIMIT_TEXT = /\b429\b|rate.?limit|1302|速率|频率/i;
25
+
26
+ const NON_RETRYABLE_STATUS = new Set([400, 401, 402, 403, 404, 413, 422]);
27
+ const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
28
+
29
+ /** Did this failure mean "too many requests"? Drives the cooldown penalty. */
30
+ export function isRateLimited(status: number | undefined, text: string): boolean {
31
+ return status === 429 || (status === undefined && RATE_LIMIT_TEXT.test(text));
32
+ }
33
+
34
+ /** Should this failure get another attempt? Explicit non-retryables win over patterns. */
35
+ export function retryableError(status: number | undefined, text: string): boolean {
36
+ if (status !== undefined) {
37
+ if (NON_RETRYABLE_STATUS.has(status)) return false;
38
+ if (RETRYABLE_STATUS.has(status)) return true;
39
+ }
40
+ if (NON_RETRYABLE_TEXT.test(text)) return false;
41
+ return RETRYABLE_TEXT.test(text);
42
+ }
43
+
44
+ /** Header lookup that tolerates any key casing the provider layer kept. */
45
+ function header(headers: Record<string, string>, name: string): string | undefined {
46
+ const lower = name.toLowerCase();
47
+ for (const k of Object.keys(headers)) {
48
+ if (k.toLowerCase() === lower) return headers[k];
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ /** Parse `retry-after` / `retry-after-ms` into ms; undefined when absent or garbage. */
54
+ export function retryAfterMs(headers: Record<string, string> | undefined): number | undefined {
55
+ if (headers === undefined) return undefined;
56
+ const ms = header(headers, "retry-after-ms");
57
+ if (ms !== undefined) {
58
+ const n = Number(ms);
59
+ if (Number.isFinite(n) && n >= 0) return n;
60
+ }
61
+ const ra = header(headers, "retry-after");
62
+ if (ra === undefined) return undefined;
63
+ const seconds = Number(ra);
64
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
65
+ const date = Date.parse(ra);
66
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
67
+ }
68
+
69
+ /** Exponential backoff with ±30% jitter, hard-capped at `maxMs`. */
70
+ export function backoffMs(attempt: number, baseMs: number, maxMs: number): number {
71
+ const raw = baseMs * 2 ** attempt * (0.7 + Math.random() * 0.6);
72
+ return Math.min(Math.round(raw), maxMs);
73
+ }
74
+
75
+ /** Numbers knobs — mirrors the optional RlmConfig fields (core/types.ts). */
76
+ export interface RetryPolicy {
77
+ /** TOTAL attempts per call, including the first. 1 = never retry. */
78
+ readonly maxAttempts: number;
79
+ /** Separate, GENEROUS budget for rate limits only: a 429 means "come back later",
80
+ * not "fail" — the call keeps parking on the cooldown window instead of dying.
81
+ * Burned only by rate-limited failures, never by 5xx/timeouts. */
82
+ readonly rateLimitMaxAttempts: number;
83
+ readonly baseDelayMs: number;
84
+ /** Cap for any single retry delay, including a parsed `retry-after`. */
85
+ readonly maxDelayMs: number;
86
+ /** First cooldown when a provider 429s without timing; doubles per consecutive strike. */
87
+ readonly throttleBaseMs: number;
88
+ /** Ceiling for the adaptive per-provider cooldown. */
89
+ readonly throttleMaxMs: number;
90
+ /** Isolation for tests; defaults to the process-wide shared cooldown. */
91
+ readonly cooldown?: ProviderCooldown;
92
+ }
93
+
94
+ export const DEFAULT_RETRY_POLICY: Readonly<RetryPolicy> = Object.freeze({
95
+ maxAttempts: 3,
96
+ rateLimitMaxAttempts: 8,
97
+ baseDelayMs: 500,
98
+ maxDelayMs: 15_000,
99
+ throttleBaseMs: 2_000,
100
+ throttleMaxMs: 60_000,
101
+ });
102
+
103
+ /** Shape of the optional retry knobs on RlmConfig — kept structural to avoid a cycle. */
104
+ export interface RetryConfigNumbers {
105
+ readonly retryMaxAttempts?: number;
106
+ readonly rateLimitMaxAttempts?: number;
107
+ readonly retryBaseDelayMs?: number;
108
+ readonly retryMaxDelayMs?: number;
109
+ readonly throttleBaseMs?: number;
110
+ readonly throttleMaxMs?: number;
111
+ }
112
+
113
+ /** Derive a policy from persisted config knobs, falling back to the defaults. */
114
+ export function retryPolicy(from: RetryConfigNumbers = {}): RetryPolicy {
115
+ return {
116
+ maxAttempts: from.retryMaxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts,
117
+ rateLimitMaxAttempts: from.rateLimitMaxAttempts ?? DEFAULT_RETRY_POLICY.rateLimitMaxAttempts,
118
+ baseDelayMs: from.retryBaseDelayMs ?? DEFAULT_RETRY_POLICY.baseDelayMs,
119
+ maxDelayMs: from.retryMaxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
120
+ throttleBaseMs: from.throttleBaseMs ?? DEFAULT_RETRY_POLICY.throttleBaseMs,
121
+ throttleMaxMs: from.throttleMaxMs ?? DEFAULT_RETRY_POLICY.throttleMaxMs,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Run `attempt` under the policy. `note` is how the caller feeds captured HTTP
127
+ * `{status, headers}` back (pi-ai's onResponse hook) — cleared before every attempt so a
128
+ * stale capture never misclassifies a fresh failure. Rate-limit failures park on the
129
+ * cooldown — their own, generous budget — so sibling requests slow down with us;
130
+ * `onPark`/`onRelease` surface the parking to the UI as a "queued" phase.
131
+ */
132
+ export async function completeWithRetry<T>(
133
+ attempt: (note: (status: number, headers: Record<string, string>) => void) => Promise<T>,
134
+ opts: {
135
+ readonly policy: RetryPolicy;
136
+ readonly provider: string;
137
+ readonly signal?: AbortSignal;
138
+ readonly onPark?: (ms: number) => void;
139
+ readonly onRelease?: () => void;
140
+ },
141
+ ): Promise<T> {
142
+ const { policy, provider, signal, onPark, onRelease } = opts;
143
+ const cooldown = policy.cooldown ?? sharedCooldown;
144
+ let status: number | undefined;
145
+ let headers: Record<string, string> | undefined;
146
+ const note = (s: number, h: Record<string, string>): void => {
147
+ status = s;
148
+ headers = h;
149
+ };
150
+ let rlTries = 0; // rate-limit failures burn their OWN budget, never maxAttempts
151
+ for (let tries = 0; ; tries++) {
152
+ await cooldown.wait(provider, signal, onPark, onRelease);
153
+ status = undefined;
154
+ headers = undefined;
155
+ try {
156
+ const out = await attempt(note);
157
+ cooldown.success(provider);
158
+ return out;
159
+ } catch (err: unknown) {
160
+ const msg = err instanceof Error ? err.message : String(err);
161
+ if (signal?.aborted) throw err;
162
+ if (isRateLimited(status, msg)) {
163
+ // "Come back later" — park on the shared cooldown instead of dying. The strike
164
+ // heuristic escalates the window; the wait itself happens at the loop top, so
165
+ // sibling requests behind the same provider slow down together.
166
+ if (rlTries + 1 >= policy.rateLimitMaxAttempts) throw err;
167
+ rlTries++;
168
+ const hint = retryAfterMs(headers);
169
+ cooldown.penalize(provider, hint !== undefined ? Math.min(hint, policy.throttleMaxMs) : undefined);
170
+ continue;
171
+ }
172
+ if (tries + 1 >= policy.maxAttempts) throw err;
173
+ if (!retryableError(status, msg)) throw err;
174
+ await sleepMs(
175
+ Math.min(retryAfterMs(headers) ?? backoffMs(tries, policy.baseDelayMs, policy.maxDelayMs), policy.maxDelayMs),
176
+ signal,
177
+ );
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Per-provider adaptive cooldown — the throttle half of rate-limit resilience.
3
+ *
4
+ * A 429 means the provider wants FEWER requests for a while, not just this one retried.
5
+ * `ProviderCooldown` holds each provider's admission time: `wait()` parks new requests
6
+ * until the window opens; `penalize()` extends it, escalating on consecutive strikes
7
+ * (base × 2^strikes, capped) so a persistent limit backs the whole fan-out off
8
+ * exponentially. Any success clears the strike counter — providers rarely announce
9
+ * recovery, so we probe again at full concurrency rather than assume the worst.
10
+ *
11
+ * `sharedCooldown` is process-wide on purpose: the provider's limit is process-wide.
12
+ * Tests inject a fresh instance via `RetryPolicy.cooldown` for isolation.
13
+ */
14
+
15
+ /** Abort-aware sleep. Rejects with "aborted" the moment `signal` fires. */
16
+ export function sleepMs(ms: number, signal?: AbortSignal): Promise<void> {
17
+ if (ms <= 0) return Promise.resolve();
18
+ return new Promise<void>((resolve, reject) => {
19
+ const timer = setTimeout((): void => {
20
+ signal?.removeEventListener("abort", onAbort);
21
+ resolve();
22
+ }, ms);
23
+ const onAbort = (): void => {
24
+ clearTimeout(timer);
25
+ reject(new Error("aborted"));
26
+ };
27
+ if (signal?.aborted) {
28
+ clearTimeout(timer);
29
+ reject(new Error("aborted"));
30
+ return;
31
+ }
32
+ signal?.addEventListener("abort", onAbort, { once: true });
33
+ });
34
+ }
35
+
36
+ export class ProviderCooldown {
37
+ private readonly blockedUntil = new Map<string, number>();
38
+ private readonly strikes = new Map<string, number>();
39
+
40
+ constructor(private readonly baseMs: number, private readonly maxMs: number) {}
41
+
42
+ /** ms until `provider` may be admitted again; 0 = free. */
43
+ waitMs(provider: string): number {
44
+ return Math.max(0, (this.blockedUntil.get(provider) ?? 0) - Date.now());
45
+ }
46
+
47
+ /** Park until the window opens. Re-checks after every wake — penalize() may extend it.
48
+ * `onPark` fires with the pending ms before each sleep (UI: "queued on rate limit"),
49
+ * `onRelease` once right after the wait ends — but only if we actually parked. */
50
+ async wait(provider: string, signal?: AbortSignal, onPark?: (ms: number) => void, onRelease?: () => void): Promise<void> {
51
+ let parked = false;
52
+ for (;;) {
53
+ const ms = this.waitMs(provider);
54
+ if (ms <= 0) break;
55
+ parked = true;
56
+ onPark?.(ms);
57
+ await sleepMs(ms, signal);
58
+ }
59
+ if (parked) onRelease?.();
60
+ }
61
+
62
+ /**
63
+ * Extend the window. `ms` (e.g. a parsed `retry-after`) floors the penalty; the strike
64
+ * heuristic (base × 2^strikes, capped) always applies on top so repeated 429s escalate
65
+ * even when the provider sends no timing at all — zai's `{"code":"1302",...}` body, for
66
+ * one, carries none.
67
+ */
68
+ penalize(provider: string, ms?: number): void {
69
+ const strikes = (this.strikes.get(provider) ?? 0) + 1;
70
+ this.strikes.set(provider, strikes);
71
+ const heuristic = Math.min(this.baseMs * 2 ** (strikes - 1), this.maxMs);
72
+ const until = Date.now() + Math.max(ms ?? 0, heuristic);
73
+ const prev = this.blockedUntil.get(provider) ?? 0;
74
+ if (until > prev) this.blockedUntil.set(provider, until);
75
+ }
76
+
77
+ /** A success means the window opened — reset escalation for the next burst. */
78
+ success(provider: string): void {
79
+ this.strikes.delete(provider);
80
+ }
81
+ }
82
+
83
+ /** Mirrored into DEFAULT_RETRY_POLICY (util/retry.ts imports these — keep one-way). */
84
+ export const THROTTLE_DEFAULTS = Object.freeze({ baseMs: 2_000, maxMs: 60_000 } as const);
85
+
86
+ /** Process-wide instance: every completion in this pi session shares it. */
87
+ export const sharedCooldown: ProviderCooldown = new ProviderCooldown(
88
+ THROTTLE_DEFAULTS.baseMs,
89
+ THROTTLE_DEFAULTS.maxMs,
90
+ );