@hicaru/pi-rlm 0.3.17 → 0.3.19
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 +9 -21
- package/package.json +1 -1
- package/src/config/defaults.ts +22 -11
- package/src/config/settings.ts +26 -14
- package/src/core/budget.ts +13 -10
- package/src/core/compaction.ts +18 -8
- package/src/core/engine.ts +11 -6
- package/src/core/limits.ts +9 -0
- package/src/core/root-context.ts +70 -12
- package/src/core/root-state.ts +88 -9
- package/src/core/run-state.ts +23 -8
- package/src/core/types.ts +2 -1
- package/src/index.ts +78 -18
- package/src/prompts/glossary.ts +5 -0
- package/src/prompts/native.ts +26 -2
- package/src/text/parsing.ts +88 -2
- package/src/ui/config-panel.ts +13 -1
- package/src/ui/status.ts +39 -4
- package/src/ui/tree/tree-model.ts +14 -7
package/README.md
CHANGED
|
@@ -43,24 +43,14 @@ models, recursively. Same Pi session, same tools, same keys: `/rlm` and go. Read
|
|
|
43
43
|
|
|
44
44
|
## Benchmarks
|
|
45
45
|
|
|
46
|
-
**OOLONG (oolong-synth)** — paper-tier long-context suite
|
|
47
|
-
cost per task from real `costUsd
|
|
46
|
+
**OOLONG (oolong-synth)** — paper-tier long-context suite (the only suite); latest
|
|
47
|
+
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` | **
|
|
52
|
-
| `google/gemma-3-27b-it` |
|
|
53
|
-
| `
|
|
54
|
-
| `mistralai/mistral-small-3.2-24b-instruct` | 66.7% | $0.0025 |
|
|
55
|
-
|
|
56
|
-
Lite suite — `needle` multi-needle recall, `codeqa` repo-QA, `coding` fix task
|
|
57
|
-
(7 tasks × 2 passes per model, deterministic graders, no LLM-as-judge):
|
|
58
|
-
|
|
59
|
-
| Model | Score | Accuracy |
|
|
60
|
-
|-------|-------|----------|
|
|
61
|
-
| `qwen/qwen3-30b-a3b-instruct-2507` | **14/14** | **100%** |
|
|
62
|
-
| `google/gemma-3-27b-it` | 12/14 | 86% |
|
|
63
|
-
| `mistralai/mistral-small-3.2-24b-instruct` | 12/14 | 86% |
|
|
51
|
+
| `qwen/qwen3.8-27b` | **87.5%** | $0.0460 |
|
|
52
|
+
| `google/gemma-3-27b-it` | 50.0% | $0.0011 |
|
|
53
|
+
| `inception/mercury-2.5` | — | — |
|
|
64
54
|
|
|
65
55
|
Raw per-task rows (correct, recall, latency, tokens, cost) live in
|
|
66
56
|
`bench/runs/*.jsonl` — one JSONL row per task, committed as history.
|
|
@@ -70,15 +60,13 @@ Raw per-task rows (correct, recall, latency, tokens, cost) live in
|
|
|
70
60
|
```bash
|
|
71
61
|
export OPENROUTER_API_KEY=sk-or-... # required — env vars are the only key transport
|
|
72
62
|
|
|
73
|
-
bun run bench #
|
|
74
|
-
bun run bench --
|
|
75
|
-
bun run bench --model openrouter/
|
|
63
|
+
bun run bench # oolong suite, default model (qwen3.8-27b)
|
|
64
|
+
bun run bench --model openrouter/google/gemma-3-27b-it
|
|
65
|
+
bun run bench --model openrouter/inception/mercury-2.5
|
|
76
66
|
bun run bench --list # print tasks, no engine / no key
|
|
77
|
-
bun run bench --suite paper # paper tier: s_niah, oolong, browsecomp, codeqa_lb (downloads datasets)
|
|
78
67
|
```
|
|
79
68
|
|
|
80
|
-
|
|
81
|
-
`oolong` · `browsecomp` · `codeqa_lb`. Regenerate the hero chart:
|
|
69
|
+
One suite (`oolong`). Regenerate the hero chart:
|
|
82
70
|
`python3 bench/hero.py` (needs `matplotlib`).
|
|
83
71
|
|
|
84
72
|
## How it works
|
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -9,7 +9,10 @@ const DEFAULT_SUB_SYSTEM_PROMPT =
|
|
|
9
9
|
export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
|
|
10
10
|
enabled: true,
|
|
11
11
|
maxDepth: 4,
|
|
12
|
-
|
|
12
|
+
// Max-long runs: the engine may keep iterating until budget/compaction walls hit. The budget
|
|
13
|
+
// cascade and compactionThresholdPct are the real length controls; this ceiling only stops
|
|
14
|
+
// truly runaway loops. Was 30 — capped long tasks prematurely.
|
|
15
|
+
maxIterations: 200,
|
|
13
16
|
execTimeoutS: 120,
|
|
14
17
|
requestTimeoutMs: 15 * 60_000,
|
|
15
18
|
// Session-wide, not per-batch: spawn() puts many requests on the wire at once, so this is
|
|
@@ -34,7 +37,11 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
|
|
|
34
37
|
maxErrors: 5,
|
|
35
38
|
orchestrator: true,
|
|
36
39
|
compaction: true,
|
|
37
|
-
|
|
40
|
+
// Compact only near the hard ceiling: ≈125K of the default 128K window (0.976). Earlier
|
|
41
|
+
// compaction (0.65) amputated usable working memory long before it was needed.
|
|
42
|
+
// DEPRECATED: ignored since the absolute 256k compaction ceiling (limits.ts); kept so old
|
|
43
|
+
// rlm.json files still load. Do not read this value in new code.
|
|
44
|
+
compactionThresholdPct: 0.976,
|
|
38
45
|
python: "python3",
|
|
39
46
|
sandboxInitTimeoutMs: 30_000,
|
|
40
47
|
contextLoader: true,
|
|
@@ -56,25 +63,29 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
|
|
|
56
63
|
// bare-number finalize gets one coached redo instead of being accepted. Opt-in via rlm.json.
|
|
57
64
|
enableVerificationNudge: false,
|
|
58
65
|
// SKILL.state integration: Σ_t execution state + cross-session distilled knowledge.
|
|
59
|
-
|
|
66
|
+
// Paradigm flags are ENFORCED (R0, /tmp/ROOT_FULL_SKILLSTATE_PLAN.md) — validateEnforcedOn
|
|
67
|
+
// forces true whatever rlm.json carries; only calibrations are tunable.
|
|
68
|
+
enableRunState: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0
|
|
60
69
|
runStateRetryMax: 2,
|
|
61
|
-
enableSkillState: true,
|
|
70
|
+
enableSkillState: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0
|
|
62
71
|
// Default ON (bench rec #3): deterministic harvest — one cheap distill leaf per finalize
|
|
63
72
|
// replaces the stochastic fence-emission harvest (0 vs 4 notes across identical ON arms).
|
|
64
|
-
enableSkillStateDistill: true,
|
|
73
|
+
enableSkillStateDistill: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0
|
|
65
74
|
skillStateMaxTokens: 1_200,
|
|
66
75
|
skillStateLeafTokens: 200,
|
|
67
76
|
skillStateMinScore: 4.0,
|
|
68
77
|
skillStateNotesPerProject: 128,
|
|
69
|
-
// Root Σ integration (WS-2..WS-4):
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
78
|
+
// Root Σ integration (WS-2..WS-4): every LLM call assembles A_t = (P, Σ_t, O_t) — discard
|
|
79
|
+
// semantics on stale payloads + exactly one Σ snapshot splice, and model-proposed ΔΣ_t
|
|
80
|
+
// fences taught in the native prompt (v2 R1/R2). Digest compaction swaps Pi's summarizer
|
|
81
|
+
// for a deterministic digest. Flags are ENFORCED (R0); RLM_BENCH_NO_ROOTCONTEXT=1 remains
|
|
82
|
+
// the dev-only A/B measurement hatch — it alters measurement, never ships as a disable path.
|
|
83
|
+
enableRootDigestCompaction: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0
|
|
73
84
|
rootDigestKeepRecentChars: 12_000,
|
|
74
85
|
rootDigestMaxChars: 8_000,
|
|
75
|
-
enableRootContextTransform:
|
|
86
|
+
enableRootContextTransform: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0 (was soak-OFF pre-v2)
|
|
76
87
|
rootContextKeepTurns: 2,
|
|
77
88
|
rootContextElideChars: 1_500,
|
|
78
89
|
rootContextSnapshot: true,
|
|
79
|
-
enableRootStateFences:
|
|
90
|
+
enableRootStateFences: true, // ENFORCED — see /tmp/ROOT_FULL_SKILLSTATE_PLAN.md R0 (was soak-OFF pre-v2)
|
|
80
91
|
});
|
package/src/config/settings.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { getAgentDir, type ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
7
7
|
import type { RlmConfig } from "../core/types.ts";
|
|
8
|
+
import { trace, traceEnabled } from "../util/trace.ts";
|
|
8
9
|
import { DEFAULT_CONFIG } from "./defaults.ts";
|
|
9
10
|
|
|
10
11
|
interface PersistedSettings {
|
|
@@ -31,6 +32,21 @@ function validateBoolean(v: unknown): boolean | undefined {
|
|
|
31
32
|
return typeof v === "boolean" ? v : undefined;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* R0 enforcement (/tmp/ROOT_FULL_SKILLSTATE_PLAN.md): the SKILL.state paradigm flags are
|
|
37
|
+
* operating law — they ALWAYS resolve to `true`, whatever rlm.json says. An explicit `false`
|
|
38
|
+
* is not an error (fail-soft by contract): it is traced (`skillstate.override-ignored`) and
|
|
39
|
+
* ignored, so a hostile or typo'd config loses visibly instead of crashing the load or
|
|
40
|
+
* silently unbounding the root context. Calibrations (window sizes, budgets) stay tunable —
|
|
41
|
+
* the PARADIGM is enforced, the calibrations are not.
|
|
42
|
+
*/
|
|
43
|
+
function validateEnforcedOn(value: unknown, flag: string): true {
|
|
44
|
+
if (value === false && traceEnabled) {
|
|
45
|
+
trace("skillstate.override-ignored", { flag, value });
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
function validateString(v: unknown): string | undefined {
|
|
35
51
|
return typeof v === "string" && v.trim() ? v : undefined;
|
|
36
52
|
}
|
|
@@ -138,15 +154,13 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
138
154
|
// Verification-discipline nudge (default OFF).
|
|
139
155
|
const enableVerificationNudge = validateBoolean(r.enableVerificationNudge);
|
|
140
156
|
if (enableVerificationNudge !== undefined) out.enableVerificationNudge = enableVerificationNudge;
|
|
141
|
-
// SKILL.state integration (Workstreams A–F)
|
|
142
|
-
|
|
143
|
-
|
|
157
|
+
// SKILL.state integration (Workstreams A–F) — paradigm flags ENFORCED (R0); runStateRetryMax
|
|
158
|
+
// is a calibration and stays tunable.
|
|
159
|
+
out.enableRunState = validateEnforcedOn(r.enableRunState, "enableRunState");
|
|
144
160
|
const runStateRetryMax = validateNumber(r.runStateRetryMax, 0);
|
|
145
161
|
if (runStateRetryMax !== undefined) out.runStateRetryMax = runStateRetryMax;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const enableSkillStateDistill = validateBoolean(r.enableSkillStateDistill);
|
|
149
|
-
if (enableSkillStateDistill !== undefined) out.enableSkillStateDistill = enableSkillStateDistill;
|
|
162
|
+
out.enableSkillState = validateEnforcedOn(r.enableSkillState, "enableSkillState");
|
|
163
|
+
out.enableSkillStateDistill = validateEnforcedOn(r.enableSkillStateDistill, "enableSkillStateDistill");
|
|
150
164
|
const skillStateMaxTokens = validateNumber(r.skillStateMaxTokens, 50);
|
|
151
165
|
if (skillStateMaxTokens !== undefined) out.skillStateMaxTokens = skillStateMaxTokens;
|
|
152
166
|
const skillStateLeafTokens = validateNumber(r.skillStateLeafTokens, 0);
|
|
@@ -155,23 +169,21 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
155
169
|
if (skillStateMinScore !== undefined) out.skillStateMinScore = skillStateMinScore;
|
|
156
170
|
const skillStateNotesPerProject = validateNumber(r.skillStateNotesPerProject, 1);
|
|
157
171
|
if (skillStateNotesPerProject !== undefined) out.skillStateNotesPerProject = skillStateNotesPerProject;
|
|
158
|
-
// Root Σ integration (WS-2..WS-4)
|
|
159
|
-
|
|
160
|
-
|
|
172
|
+
// Root Σ integration (WS-2..WS-4) — paradigm flags ENFORCED (R0); the window/byte knobs
|
|
173
|
+
// below are calibrations and stay tunable.
|
|
174
|
+
out.enableRootDigestCompaction = validateEnforcedOn(r.enableRootDigestCompaction, "enableRootDigestCompaction");
|
|
161
175
|
const rootDigestKeepRecentChars = validateNumber(r.rootDigestKeepRecentChars, 200);
|
|
162
176
|
if (rootDigestKeepRecentChars !== undefined) out.rootDigestKeepRecentChars = rootDigestKeepRecentChars;
|
|
163
177
|
const rootDigestMaxChars = validateNumber(r.rootDigestMaxChars, 200);
|
|
164
178
|
if (rootDigestMaxChars !== undefined) out.rootDigestMaxChars = rootDigestMaxChars;
|
|
165
|
-
|
|
166
|
-
if (enableRootContextTransform !== undefined) out.enableRootContextTransform = enableRootContextTransform;
|
|
179
|
+
out.enableRootContextTransform = validateEnforcedOn(r.enableRootContextTransform, "enableRootContextTransform");
|
|
167
180
|
const rootContextKeepTurns = validateNumber(r.rootContextKeepTurns, 0);
|
|
168
181
|
if (rootContextKeepTurns !== undefined) out.rootContextKeepTurns = rootContextKeepTurns;
|
|
169
182
|
const rootContextElideChars = validateNumber(r.rootContextElideChars, 100);
|
|
170
183
|
if (rootContextElideChars !== undefined) out.rootContextElideChars = rootContextElideChars;
|
|
171
184
|
const rootContextSnapshot = validateBoolean(r.rootContextSnapshot);
|
|
172
185
|
if (rootContextSnapshot !== undefined) out.rootContextSnapshot = rootContextSnapshot;
|
|
173
|
-
|
|
174
|
-
if (enableRootStateFences !== undefined) out.enableRootStateFences = enableRootStateFences;
|
|
186
|
+
out.enableRootStateFences = validateEnforcedOn(r.enableRootStateFences, "enableRootStateFences");
|
|
175
187
|
if (typeof r.subSampling === "object" && r.subSampling !== null) {
|
|
176
188
|
const ss = r.subSampling as Record<string, unknown>;
|
|
177
189
|
const sampling: { maxTokens?: number; temperature?: number; reasoning?: ThinkingLevel } = {};
|
package/src/core/budget.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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:
|
|
4
|
+
* The budget is the PRIMARY run-length control: above COMPACTION_CEILING_TOKENS the cap is
|
|
5
|
+
* max(ceiling, budgetShare × model context window) — the share can only stretch the working
|
|
6
|
+
* budget further out, never cut under the ceiling;
|
|
5
7
|
* one soft wrap-up turn at `softFrac` of the cap, and at the hard cap a deterministic
|
|
6
8
|
* handoff (`distillTrajectory`) is handed to a fresh continuation run — chain-capped at
|
|
7
9
|
* `maxContinuations`. Wall-clock timeouts stay only as hang backstops.
|
|
@@ -16,6 +18,7 @@ import type { ChatMsg } from "../bridge/model.ts";
|
|
|
16
18
|
import type { RlmConfig } from "./types.ts";
|
|
17
19
|
import type { RunState } from "./run-state.ts";
|
|
18
20
|
import { compactJSON } from "./run-state.ts";
|
|
21
|
+
import { COMPACTION_CEILING_TOKENS } from "./limits.ts";
|
|
19
22
|
|
|
20
23
|
interface TokenBudgetOptions {
|
|
21
24
|
readonly softFrac?: number;
|
|
@@ -110,14 +113,13 @@ export class TokenBudget {
|
|
|
110
113
|
/**
|
|
111
114
|
* Minimum context window (tokens) for the token-budget cascade to engage at all.
|
|
112
115
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
116
|
+
* LO rule (2025-09-09): windows at/below COMPACTION_CEILING_TOKENS (256k) are never
|
|
117
|
+
* budget-amputated — the derived share would shrink below a task's FIXED overhead (system
|
|
118
|
+
* prompt + per-turn history re-send + sub-LLM calls); a 32k window would cap a task at 8k
|
|
119
|
+
* tokens, less than the protocol scaffolding alone. Windows above the ceiling are budgeted
|
|
120
|
+
* AT the ceiling, never below it. Unbounded runs stay bounded by
|
|
121
|
+
* maxIterations / maxErrors / wall-clock instead.
|
|
119
122
|
*/
|
|
120
|
-
export const BUDGET_WINDOW_FLOOR = 250_000;
|
|
121
123
|
|
|
122
124
|
/** One TokenBudget construction shape — the cap varies, the policy knobs never do (DRY). */
|
|
123
125
|
function makeBudget(config: RlmConfig, cap: number): TokenBudget {
|
|
@@ -135,8 +137,9 @@ function unboundedBudget(config: RlmConfig): TokenBudget {
|
|
|
135
137
|
|
|
136
138
|
export function resolveBudget(contextWindow: number | undefined, config: RlmConfig): TokenBudget {
|
|
137
139
|
const ctx = contextWindow !== undefined && contextWindow > 0 ? contextWindow : 32_000;
|
|
138
|
-
if (ctx
|
|
139
|
-
|
|
140
|
+
if (ctx <= COMPACTION_CEILING_TOKENS) return unboundedBudget(config);
|
|
141
|
+
// The share only stretches the budget BEYOND the absolute ceiling — never under it.
|
|
142
|
+
const shareCap = Math.max(COMPACTION_CEILING_TOKENS, Math.floor(ctx * config.budgetShare));
|
|
140
143
|
const cap = config.budgetTaskCap > 0 ? Math.min(shareCap, config.budgetTaskCap) : shareCap;
|
|
141
144
|
return makeBudget(config, Math.max(cap, 1));
|
|
142
145
|
}
|
package/src/core/compaction.ts
CHANGED
|
@@ -13,8 +13,7 @@ import type { RetryPolicy } from "../util/retry.ts";
|
|
|
13
13
|
import { estimateMessageTokens } from "../text/tokens.ts";
|
|
14
14
|
import type { RunState } from "../core/run-state.ts";
|
|
15
15
|
import { compactJSON } from "../core/run-state.ts";
|
|
16
|
-
|
|
17
|
-
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
16
|
+
import { COMPACTION_CEILING_TOKENS } from "./limits.ts";
|
|
18
17
|
|
|
19
18
|
const SUMMARY_REQUEST =
|
|
20
19
|
"Summarize your progress so far. Include: (1) which sub-tasks are done and which remain; " +
|
|
@@ -25,17 +24,18 @@ interface CompactionDeps {
|
|
|
25
24
|
readonly model: Model<Api>;
|
|
26
25
|
readonly registry: ModelRegistry;
|
|
27
26
|
readonly contextWindow?: number;
|
|
28
|
-
readonly thresholdPct?: number;
|
|
29
27
|
readonly signal?: AbortSignal;
|
|
30
28
|
/** v5.1 retry policy for modelComplete; defaults apply when omitted. */
|
|
31
29
|
readonly retry?: RetryPolicy;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
/**
|
|
33
|
+
* True if the history is at/over the compaction threshold — the ABSOLUTE
|
|
34
|
+
* COMPACTION_CEILING_TOKENS (LO rule 2025-09-09): windows ≤ 256k never compact; larger
|
|
35
|
+
* windows compact exactly at 256k. `contextWindow`/`thresholdPct` percentage math is gone.
|
|
36
|
+
*/
|
|
37
|
+
export function shouldCompact(history: ChatMsg[]): boolean {
|
|
38
|
+
return estimateMessageTokens(history) >= COMPACTION_CEILING_TOKENS;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
@@ -142,6 +142,16 @@ export function rebaseWithState(history: ChatMsg[], state: RunState, count = 1):
|
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
+
// Token-bounded tail (LO rule 2025-09-09): the kept turns must also fit under the absolute
|
|
146
|
+
// ceiling; walk tailStart forward until the tail does. Σ carries everything dropped turns held.
|
|
147
|
+
const sizes: number[] = new Array<number>(history.length);
|
|
148
|
+
for (let i = 0; i < history.length; i++) sizes[i] = estimateMessageTokens([history[i]]);
|
|
149
|
+
let tailTokens = 0;
|
|
150
|
+
for (let i = tailStart; i < history.length; i++) tailTokens += sizes[i];
|
|
151
|
+
while (tailStart < history.length && tailTokens > COMPACTION_CEILING_TOKENS) {
|
|
152
|
+
tailTokens -= sizes[tailStart];
|
|
153
|
+
tailStart += 1;
|
|
154
|
+
}
|
|
145
155
|
const window: ChatMsg[] = tailStart < history.length ? history.slice(tailStart) : [];
|
|
146
156
|
return [
|
|
147
157
|
...head,
|
package/src/core/engine.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox
|
|
|
26
26
|
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
27
27
|
import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
|
|
28
28
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
29
|
-
import { findReplBlocks } from "../text/parsing.ts";
|
|
29
|
+
import { findReplBlocks, stripStateFences } from "../text/parsing.ts";
|
|
30
30
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
31
31
|
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
32
32
|
import { compactHistory, elideOldToolPayloads, rebaseWithState, shouldCompact } from "./compaction.ts";
|
|
@@ -364,16 +364,16 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
364
364
|
// v5 G1 first: elide old tool payloads head+tail — often avoids the summary entirely.
|
|
365
365
|
history = elideOldToolPayloads(history);
|
|
366
366
|
const compactionDeps = {
|
|
367
|
-
// Summarisation is done by the cheap worker model;
|
|
368
|
-
//
|
|
367
|
+
// Summarisation is done by the cheap worker model; compaction fires on the ABSOLUTE
|
|
368
|
+
// COMPACTION_CEILING_TOKENS (limits.ts): ≤256k windows never compact, larger ones
|
|
369
|
+
// compact exactly at 256k (LO rule 2025-09-09).
|
|
369
370
|
model: deps.llmModel,
|
|
370
371
|
registry: deps.registry,
|
|
371
372
|
contextWindow: model.contextWindow,
|
|
372
|
-
thresholdPct: deps.config.compactionThresholdPct,
|
|
373
373
|
retry: retryPolicy(deps.config),
|
|
374
374
|
signal: deps.signal,
|
|
375
375
|
};
|
|
376
|
-
if (shouldCompact(history
|
|
376
|
+
if (shouldCompact(history)) {
|
|
377
377
|
// Workstream A: with Σ active, rebase structurally — [P, Σ_t, window(O)] — and
|
|
378
378
|
// the summarizer call disappears entirely; degraded runs keep compactHistory.
|
|
379
379
|
history = runStateMode.kind === "active"
|
|
@@ -602,8 +602,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
602
602
|
}
|
|
603
603
|
|
|
604
604
|
function result(answer: string, iterations: number, limits: LimitGuard): RlmResult {
|
|
605
|
+
// State fences are a Σ transport, never user-visible output (§7): scrub them from the
|
|
606
|
+
// FINAL answer. A fence-only answer means the model spent its last turn committing state
|
|
607
|
+
// and never re-answered — surface the stub instead of a raw patch JSON.
|
|
608
|
+
const clean = stripStateFences(answer);
|
|
609
|
+
const final = clean.trim().length > 0 ? clean.trim() : "(no final answer — last turn committed state only; see Σ)";
|
|
605
610
|
const u = limits.usage();
|
|
606
|
-
return { answer, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
|
|
611
|
+
return { answer: final, iterations, costUsd: u.costUsd, inputTokens: u.inputTokens, outputTokens: u.outputTokens, durationMs: u.durationMs };
|
|
607
612
|
}
|
|
608
613
|
|
|
609
614
|
/** Model metadata window, else the offline registry fallback (disk cache → table → 32k). */
|
package/src/core/limits.ts
CHANGED
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Absolute working ceiling (LO rule 2025-09-09): model windows AT/BELOW this value are never
|
|
13
|
+
* compacted or budget-amputated — the agent runs its full window. Windows ABOVE it are
|
|
14
|
+
* compacted/budgeted exactly AT the ceiling (e.g. a 1M-context model works up to ~256k tokens
|
|
15
|
+
* of history, then rebases/compacts). Single source of truth for budget.ts (resolveBudget)
|
|
16
|
+
* and compaction.ts (shouldCompact + rebaseWithState token bound).
|
|
17
|
+
*/
|
|
18
|
+
export const COMPACTION_CEILING_TOKENS = 256_000;
|
|
19
|
+
|
|
11
20
|
export interface Limits {
|
|
12
21
|
readonly maxTimeoutMs?: number;
|
|
13
22
|
readonly maxTokens?: number;
|
package/src/core/root-context.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { ContextEvent } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import type { RunState } from "./run-state.ts";
|
|
21
21
|
import { runStateRootBlock } from "./run-state.ts";
|
|
22
|
+
import { ROOT_TURN_ELIDED_LINE } from "../prompts/glossary.ts";
|
|
22
23
|
import { truncateOutput } from "../text/parsing.ts";
|
|
23
24
|
import { textContentOf } from "../text/agent-text.ts";
|
|
24
25
|
|
|
@@ -33,15 +34,29 @@ export interface ElideOptions {
|
|
|
33
34
|
const ELIDE_MARK = "chars elided — full result in session log";
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
|
-
* WS-3a: elide stale
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* WS-3a: elide stale turns. The newest `keepTurns` assistant turns and the final user message
|
|
38
|
+
* stay verbatim. Older turns collapse in two tiers (§5.3 discard semantics + R5/G4 honest
|
|
39
|
+
* strictness, and the only way the R6 acceptance bound — per-call ≤ Σ + window — can hold):
|
|
40
|
+
* - recent stale ring (the last `max(keepTurns, 1)` stale turns): toolResult payloads over
|
|
41
|
+
* `elideChars` become head+tail previews (same truncation shape as repl stdout); assistant
|
|
42
|
+
* prose always collapses to the one-line stub.
|
|
43
|
+
* - older still: EVERYTHING (payloads included) becomes the one-line session-log stub —
|
|
44
|
+
* a stale preview per turn would itself accumulate linearly and re-create O(T).
|
|
45
|
+
* `role:"custom"` messages of the Σ/intro kinds are immune (WS-3b owns them). Mutates the
|
|
46
|
+
* array in place; returns the number of messages elided (telemetry), for zero-cost counters.
|
|
41
47
|
*/
|
|
42
48
|
export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions): number {
|
|
43
49
|
const keepTurns = Math.max(0, Math.floor(opts.keepTurns));
|
|
44
|
-
if (
|
|
50
|
+
if (messages.length === 0) return 0;
|
|
51
|
+
// Preview ring: stale turns recent enough to deserve the §5.3 head+tail preview. Scales with
|
|
52
|
+
// the window knob — one calibration, two tiers (preview ring, then stub) — and never zero,
|
|
53
|
+
// so keepTurns=0 still previews the single closest stale payload instead of stubbing blind.
|
|
54
|
+
const previewRing = Math.max(keepTurns, 1);
|
|
55
|
+
if (keepTurns === 0) {
|
|
56
|
+
// R5 strict-0: nothing inside the window — Σ + immune customs + the final user message
|
|
57
|
+
// are all that survive verbatim.
|
|
58
|
+
return elideRange(messages, 0, messages.length, opts, previewRing);
|
|
59
|
+
}
|
|
45
60
|
|
|
46
61
|
// Index of the assistant message that opens the keepTurns-th-from-last turn — everything
|
|
47
62
|
// from there on is the protected tail (same walk as core/compaction.ts elideOldToolPayloads).
|
|
@@ -57,15 +72,55 @@ export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions):
|
|
|
57
72
|
}
|
|
58
73
|
}
|
|
59
74
|
if (tailStart <= 0) return 0; // fewer turns than the window — nothing to elide
|
|
75
|
+
return elideRange(messages, 0, tailStart, opts, previewRing);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Custom messages the elision never touches — the Σ snapshot/observation and the intro. */
|
|
79
|
+
const IMMUNE_CUSTOM_TYPES: ReadonlySet<string> = new Set(["rlm-sigma", "rlm-sigma-observation", "rlm-intro"]);
|
|
80
|
+
|
|
81
|
+
function isImmuneCustom(m: RootMessage): boolean {
|
|
82
|
+
if (m.role !== "custom") return false;
|
|
83
|
+
const customType: unknown = (m as { customType?: unknown }).customType;
|
|
84
|
+
return typeof customType === "string" && IMMUNE_CUSTOM_TYPES.has(customType);
|
|
85
|
+
}
|
|
60
86
|
|
|
87
|
+
/** R5: elide [from, to) — stale assistant prose always becomes the one-line Σ stub; stale
|
|
88
|
+
* toolResults get the §5.3 preview while `assistantsAfter < previewRing` and the stub beyond
|
|
89
|
+
* (two-tier elision — previews must not accumulate linearly). Immune customs and the final
|
|
90
|
+
* user message are never touched. Mutates in place; returns the elided count. */
|
|
91
|
+
function elideRange(
|
|
92
|
+
messages: RootMessage[],
|
|
93
|
+
from: number,
|
|
94
|
+
to: number,
|
|
95
|
+
opts: ElideOptions,
|
|
96
|
+
previewRing: number,
|
|
97
|
+
): number {
|
|
61
98
|
const lastUser = lastIndexOfRole(messages, "user");
|
|
99
|
+
// Pre-pass: stale assistant indices in [from, to) — a payload's recency is measured by the
|
|
100
|
+
// stale turns AFTER it (pre-allocated walk, no per-message allocation).
|
|
101
|
+
let staleAssistants = 0;
|
|
102
|
+
for (let i = from; i < to; i++) {
|
|
103
|
+
if (messages[i]?.role === "assistant") staleAssistants += 1;
|
|
104
|
+
}
|
|
62
105
|
let elided = 0;
|
|
63
|
-
for (let i =
|
|
106
|
+
for (let i = from; i < to; i++) {
|
|
64
107
|
const m = messages[i];
|
|
65
|
-
if (m === undefined || m
|
|
108
|
+
if (m === undefined || isImmuneCustom(m)) continue;
|
|
66
109
|
if (i === lastUser) continue; // paranoia: the final user message is never touched
|
|
67
|
-
|
|
68
|
-
|
|
110
|
+
if (m.role === "assistant") {
|
|
111
|
+
staleAssistants -= 1; // turns AFTER this one = count minus itself
|
|
112
|
+
messages[i] = { ...m, content: [{ type: "text", text: ROOT_TURN_ELIDED_LINE }] } as RootMessage;
|
|
113
|
+
elided += 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (m.role !== "toolResult") continue;
|
|
117
|
+
if (staleAssistants >= previewRing) {
|
|
118
|
+
// Deep-stale payload: even the preview would accumulate — collapse to the stub.
|
|
119
|
+
messages[i] = { ...m, content: [{ type: "text", text: ROOT_TURN_ELIDED_LINE }] } as RootMessage;
|
|
120
|
+
elided += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (totalTextLength(m) <= opts.elideChars) continue;
|
|
69
124
|
messages[i] = {
|
|
70
125
|
...m,
|
|
71
126
|
content: [{ type: "text", text: previewToolText(m, opts.elideChars) }],
|
|
@@ -75,13 +130,16 @@ export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions):
|
|
|
75
130
|
return elided;
|
|
76
131
|
}
|
|
77
132
|
|
|
78
|
-
/** WS-3b: exactly one live Σ snapshot, immediately before the LAST user message.
|
|
133
|
+
/** WS-3b: exactly one live Σ snapshot, immediately before the LAST user message.
|
|
134
|
+
* R2 (G2): `withContract` makes the splice carry the fence contract (A.4 authoring mode) —
|
|
135
|
+
* pass-through to runStateRootBlock; without it, the v1 observation-only block. */
|
|
79
136
|
export function spliceSigmaSnapshot(
|
|
80
137
|
messages: RootMessage[],
|
|
81
138
|
state: RunState,
|
|
82
139
|
rectifyHint: string | undefined,
|
|
140
|
+
opts?: { readonly withContract?: boolean },
|
|
83
141
|
): void {
|
|
84
|
-
const block = runStateRootBlock(state);
|
|
142
|
+
const block = runStateRootBlock(state, opts);
|
|
85
143
|
const text = rectifyHint === undefined ? block : `${block}\n${rectifyHint}`;
|
|
86
144
|
// Remove any previous instance (only one lives at a time — idempotent across calls).
|
|
87
145
|
for (let i = messages.length - 1; i >= 0; i--) {
|
package/src/core/root-state.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* controller/sandboxManager. It is runtime-derived — tool outcomes, engine-run mirrors,
|
|
7
7
|
* the user's latest prompt — zero model cooperation required (paper §5.3: observation
|
|
8
8
|
* override; §5.7: small models must not be the state's author by default). The optional
|
|
9
|
-
* fence protocol (enableRootStateFences
|
|
9
|
+
* fence protocol (enableRootStateFences — ENFORCED ON since Root Σ v2 R0) is the only
|
|
10
|
+
* model-proposed input and
|
|
10
11
|
* rides the SAME V(ΔΣ_t,Σ_t) validator + retry/degrade ladder as engine runs.
|
|
11
12
|
*
|
|
12
13
|
* Caps/dedup/serialization are the engine's own machinery: RUN_STATE_LIMITS, dedupStrings,
|
|
@@ -35,6 +36,23 @@ const RECTIFY_FAILURE_THRESHOLD = 2;
|
|
|
35
36
|
/** Task restatement cap — mirrors run-state.ts TASK_MAX_CHARS (kept in sync by comment). */
|
|
36
37
|
const ROOT_TASK_MAX_CHARS = 200;
|
|
37
38
|
|
|
39
|
+
/** R4: idle-degrade threshold for the NATIVE root tracker — deliberately ROOT-SPECIFIC
|
|
40
|
+
* (soak finding, 2025-09-08 live sessions: qwen3.8-27b ×3, qwen3-30b/32b, gemini-flash).
|
|
41
|
+
* The engine's `RUN_STATE_IDLE_DEGRADE_TURNS = 4` is bench-tuned for runs conditioned on Σ
|
|
42
|
+
* from turn 1; NATIVE sessions have a cold-start ramp — first fences land on turn 3 (short
|
|
43
|
+
* tasks) or turn 5–6 (study tasks), so 4 amputated exactly before the first commit (2/2
|
|
44
|
+
* study sessions degraded at 4, then fenced at 5). 6 clears the observed ramp while still
|
|
45
|
+
* bounding the fence tax. The engine const and its tuning are untouched.
|
|
46
|
+
*/
|
|
47
|
+
export const ROOT_IDLE_DEGRADE_TURNS = 6;
|
|
48
|
+
|
|
49
|
+
/** R3 soak observability: per-turn fence outcome, returned by applyFences. */
|
|
50
|
+
export interface FenceOutcome {
|
|
51
|
+
readonly fences: number;
|
|
52
|
+
readonly accepted: number;
|
|
53
|
+
readonly problems: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
/** Root Σ mode — same discriminated union shape as the engine's (active | degraded). */
|
|
39
57
|
type RootStateMode =
|
|
40
58
|
| { readonly kind: "active"; readonly retries: number }
|
|
@@ -45,6 +63,8 @@ export class RootStateTracker {
|
|
|
45
63
|
private mode: RootStateMode = { kind: "active", retries: 0 };
|
|
46
64
|
private opCounter = 0;
|
|
47
65
|
private dirtyFlag = false;
|
|
66
|
+
/** R4: consecutive fence-eligible turns with zero accepted deltas (idle streak). */
|
|
67
|
+
private idleFenceTurns = 0;
|
|
48
68
|
private readonly failures = new Map<string, number>();
|
|
49
69
|
private pendingObservation: string | undefined;
|
|
50
70
|
private readonly retryMax: number;
|
|
@@ -82,6 +102,21 @@ export class RootStateTracker {
|
|
|
82
102
|
return this.dirtyFlag;
|
|
83
103
|
}
|
|
84
104
|
|
|
105
|
+
/** R4: true while the tracker accepts fences and the context transform may splice Σ. */
|
|
106
|
+
get isActive(): boolean {
|
|
107
|
+
return this.mode.kind === "active";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** R4 telemetry: consecutive fence-eligible turns with zero accepted deltas. */
|
|
111
|
+
get idleTurns(): number {
|
|
112
|
+
return this.idleFenceTurns;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** R4 telemetry: the degrade reason while degraded; undefined while active. */
|
|
116
|
+
get degradeReason(): string | undefined {
|
|
117
|
+
return this.mode.kind === "degraded" ? this.mode.reason : undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
85
120
|
snapshot(): RunState {
|
|
86
121
|
const capped = enforceCaps(this.draft);
|
|
87
122
|
// enforceCaps never fails in practice; the fallback keeps the tracker fail-soft anyway.
|
|
@@ -160,11 +195,32 @@ export class RootStateTracker {
|
|
|
160
195
|
* deltas land sequentially, ALL problems accumulate into ONE observation (error-as-
|
|
161
196
|
* observation), and only past `runStateRetryMax` total rejections the tracker degrades and
|
|
162
197
|
* fences stop being applied. Wording delegates to run-state.ts (N1) — one source.
|
|
198
|
+
*
|
|
199
|
+
* R4 (G6, /tmp/ROOT_FULL_SKILLSTATE_PLAN.md): idle-degrade parity with the engine — once
|
|
200
|
+
* the native prompt teaches the fence contract, EVERY finalized assistant turn is
|
|
201
|
+
* fence-eligible; a turn with zero accepted deltas grows `idleFenceTurns` and
|
|
202
|
+
* `ROOT_IDLE_DEGRADE_TURNS` consecutive idle turns degrade the tracker (an idle Σ is
|
|
203
|
+
* pure input tax — bench rec #2, paper §5.7; root threshold is 6, not the engine's 4 —
|
|
204
|
+
* see the const's soak citation). Any accepted delta resets the streak. In
|
|
205
|
+
* degraded mode fences stop applying and the context transform stops splicing (`isActive`),
|
|
206
|
+
* while runtime `observeToolResult` remains the Σ floor (degrade, never crash).
|
|
163
207
|
*/
|
|
164
|
-
applyFences(fences: readonly StateFenceResult[]):
|
|
165
|
-
|
|
208
|
+
applyFences(fences: readonly StateFenceResult[]): FenceOutcome {
|
|
209
|
+
// R7-fix (recoverable degrade): a DEGRADED tracker no longer drops fences on the floor.
|
|
210
|
+
// The old early-return turned idle degrade into a one-way amnesia valve — every later
|
|
211
|
+
// fence vanished silently while the context transform kept eliding turns. Now the same
|
|
212
|
+
// validation ladder runs in degraded mode, and a CLEAN batch re-activates compensation.
|
|
213
|
+
// Degrade stays sticky only against zero-progress storms (all-malformed batches).
|
|
214
|
+
if (fences.length === 0) {
|
|
215
|
+
// R4 (G6): a fence-free turn on a conditioned loop is IDLE — the contract rode the
|
|
216
|
+
// prompt for nothing. Grow the streak; degrade at the engine's threshold.
|
|
217
|
+
this.idleFenceTurns += 1;
|
|
218
|
+
this.degradeIfIdle();
|
|
219
|
+
return { fences: 0, accepted: 0, problems: 0 };
|
|
220
|
+
}
|
|
166
221
|
let state = this.snapshot();
|
|
167
222
|
const problems: string[] = [];
|
|
223
|
+
let accepted = 0;
|
|
168
224
|
for (const fence of fences) {
|
|
169
225
|
if (!fence.ok) {
|
|
170
226
|
problems.push(malformedFenceProblem(fence.error));
|
|
@@ -173,27 +229,50 @@ export class RootStateTracker {
|
|
|
173
229
|
const next: Result<RunState, PatchError> = applyPatch(state, fence.value, ++this.opCounter);
|
|
174
230
|
if (next.ok) {
|
|
175
231
|
state = next.value;
|
|
232
|
+
accepted += 1;
|
|
176
233
|
} else {
|
|
177
234
|
problems.push(patchErrorText(next.error));
|
|
178
235
|
}
|
|
179
236
|
}
|
|
237
|
+
// Accepted deltas reset the idle streak — even in a partially-failing batch (engine
|
|
238
|
+
// parity: real work is never punished for a sibling's malformed fence).
|
|
239
|
+
this.idleFenceTurns = accepted > 0 ? 0 : this.idleFenceTurns + 1;
|
|
240
|
+
this.degradeIfIdle();
|
|
180
241
|
if (problems.length === 0) {
|
|
242
|
+
// Recovery seam: a clean batch re-activates a degraded tracker, so one honest fence
|
|
243
|
+
// ends the amnesia window instead of requiring a session restart.
|
|
181
244
|
this.mode = { kind: "active", retries: 0 };
|
|
182
245
|
this.pendingObservation = undefined;
|
|
183
246
|
this.draft = this.toMutable(state);
|
|
184
247
|
this.touch();
|
|
185
|
-
return;
|
|
248
|
+
return { fences: fences.length, accepted, problems: 0 };
|
|
186
249
|
}
|
|
187
|
-
|
|
250
|
+
// Degraded variant carries `reason`, not `retries` — recovery is clean-batch-only, so a
|
|
251
|
+
// degraded tracker neither accumulates retries nor re-activates on a partial batch.
|
|
252
|
+
const retries = (this.mode.kind === "active" ? this.mode.retries : 0) + problems.length;
|
|
188
253
|
this.pendingObservation = statePatchObservation(problems);
|
|
189
254
|
// Accepted deltas in a partially-failing batch still land — engine parity: real work is
|
|
190
255
|
// never rolled back just because a sibling fence was malformed.
|
|
191
256
|
this.draft = this.toMutable(state);
|
|
192
257
|
this.touch();
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
258
|
+
if (this.mode.kind === "active") {
|
|
259
|
+
if (retries > this.retryMax) {
|
|
260
|
+
this.mode = { kind: "degraded", reason: `state-patch retry cap exceeded (${retries} rejected)` };
|
|
261
|
+
} else {
|
|
262
|
+
// Persist the running rejection count — the retry cap is CUMULATIVE across turns.
|
|
263
|
+
this.mode = { kind: "active", retries };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { fences: fences.length, accepted, problems: problems.length };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** R4: fire the idle degrade at the root threshold (active trackers only). */
|
|
270
|
+
private degradeIfIdle(): void {
|
|
271
|
+
if (this.mode.kind === "active" && this.idleFenceTurns >= ROOT_IDLE_DEGRADE_TURNS) {
|
|
272
|
+
this.mode = {
|
|
273
|
+
kind: "degraded",
|
|
274
|
+
reason: `idle degrade — ${this.idleFenceTurns} consecutive turns with zero accepted deltas`,
|
|
275
|
+
};
|
|
197
276
|
}
|
|
198
277
|
}
|
|
199
278
|
|
package/src/core/run-state.ts
CHANGED
|
@@ -552,26 +552,41 @@ export function statePatchObservation(problems: readonly string[]): string | und
|
|
|
552
552
|
}
|
|
553
553
|
|
|
554
554
|
export const STATE_FENCE_INSTRUCTION: string =
|
|
555
|
-
"[state]
|
|
555
|
+
"[state] Your user-facing reply is normal prose — a readable report. The ```state fence is OPTIONAL compact metadata that trails it, never a replacement for the report:\n" +
|
|
556
556
|
'{"state_patch": {"verifiedFacts[+]": "src/x.ts — fact", "testedApproaches.h1.status": "failed"}}\n' +
|
|
557
|
+
"Σ is an index of pointers, not a report: ≤ 5 keys per patch, every string value ≤ 120 chars, " +
|
|
558
|
+
"telegraphic style (`path — fact`, `verdict — numbers`). NEVER paste findings, tables, JSON " +
|
|
559
|
+
"blobs, or long excerpts into Σ — the prose carries the story, Σ carries only the pointers.\n" +
|
|
557
560
|
"Keys: dotted paths write record leaves; [+] appends; [N] sets an array slot; null deletes.\n" +
|
|
558
561
|
"Commit DELTAS only — never restate unchanged records or arrays; touch single dotted keys " +
|
|
559
562
|
`or append with [+]. Whole-record restatements must keep EVERY key (implicit drops are ` +
|
|
560
563
|
`rejected), and a patch over ${RUN_STATE_LIMITS.patchBytes} bytes is rejected whole.\n` +
|
|
561
|
-
"
|
|
564
|
+
"If this turn produced nothing durable and new, end your reply on the prose — NO fence at all. " +
|
|
565
|
+
"An absent fence is free; a malformed or oversized one costs a retry. " +
|
|
566
|
+
"Stale turns are elided and compensated by Σ, so a durable fact you skip here is gone.";
|
|
567
|
+
|
|
568
|
+
/** ONE Σ-block composer (R2): the engine turn block and the root splice both delegate here —
|
|
569
|
+
* never duplicate the contract + Σ assembly. `withContract` is paper A.4 authoring mode;
|
|
570
|
+
* without it, observation-only mode. */
|
|
571
|
+
function sigmaBlock(state: RunState, withContract: boolean): string {
|
|
572
|
+
const sigma = `[Σ] ${compactJSON(state)}`;
|
|
573
|
+
return withContract ? `${STATE_FENCE_INSTRUCTION}\n\n${sigma}` : sigma;
|
|
574
|
+
}
|
|
562
575
|
|
|
563
576
|
/** The per-turn A_t block: the fence contract + the current Σ (paper A_t = (P, Σ_t, O_t)). */
|
|
564
577
|
export function runStateTurnBlock(state: RunState): string {
|
|
565
|
-
return
|
|
578
|
+
return sigmaBlock(state, true);
|
|
566
579
|
}
|
|
567
580
|
|
|
568
581
|
/**
|
|
569
|
-
* Root Σ (WS-3b): the ROOT's A_t block
|
|
570
|
-
*
|
|
571
|
-
*
|
|
582
|
+
* Root Σ (WS-3b): the ROOT's A_t block. R2 (G2): with `withContract` the splice carries the
|
|
583
|
+
* SAME fence contract (delegating to the one composer above — no re-wording), making the
|
|
584
|
+
* splice paper-faithful A.4 authoring mode; without it, byte-identical to the v1
|
|
585
|
+
* observation-only snapshot. Recall line comes from the glossary (one wording source).
|
|
572
586
|
*/
|
|
573
|
-
export function runStateRootBlock(state: RunState): string {
|
|
574
|
-
return
|
|
587
|
+
export function runStateRootBlock(state: RunState, opts?: { readonly withContract?: boolean }): string {
|
|
588
|
+
return sigmaBlock(state, opts?.withContract === true) +
|
|
589
|
+
"\n" +
|
|
575
590
|
"Fresh tool results outrank Σ when they disagree.\n" +
|
|
576
591
|
`[Project facts recall: skill_search()] — ${SKILL_RECALL_LINE}`;
|
|
577
592
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -54,7 +54,8 @@ export interface RlmConfig {
|
|
|
54
54
|
readonly orchestrator: boolean;
|
|
55
55
|
/** Summarize the trajectory when it grows past the threshold (keeps the root window small). */
|
|
56
56
|
readonly compaction: boolean;
|
|
57
|
-
/**
|
|
57
|
+
/** DEPRECATED (LO rule 2025-09-09): ignored — compaction is governed by the absolute
|
|
58
|
+
* COMPACTION_CEILING_TOKENS (limits.ts). Kept for settings/UI compatibility only. */
|
|
58
59
|
readonly compactionThresholdPct: number;
|
|
59
60
|
/** Python executable used to launch the sandbox worker. */
|
|
60
61
|
readonly python: string;
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts"
|
|
|
13
13
|
import { RlmController } from "./mode/rlm-mode.ts";
|
|
14
14
|
import { cheapestModel } from "./mode/llm-model.ts";
|
|
15
15
|
import { postRlmGuide } from "./ui/intro.ts";
|
|
16
|
-
import { setRlmModeStatus } from "./ui/status.ts";
|
|
16
|
+
import { setRlmModeStatus, type RootSigmaTelemetry } from "./ui/status.ts";
|
|
17
17
|
import { RunRegistry } from "./ui/panel/run-registry.ts";
|
|
18
18
|
import { installTreePanel } from "./ui/panel/tree-panel.ts";
|
|
19
19
|
import { markdownTheme } from "./ui/theme-adapter.ts";
|
|
@@ -113,11 +113,20 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
113
113
|
/** Root Σ (WS-3/4): the native session's digest-level Σ_t — runtime-derived (tool outcomes,
|
|
114
114
|
* engine mirrors, prompts); lazily born on the first prompt, harvested + dropped at shutdown. */
|
|
115
115
|
let rootTracker: RootStateTracker | undefined;
|
|
116
|
-
// Root Σ WS-5.1 telemetry — journal counters (trace lines
|
|
116
|
+
// Root Σ WS-5.1 telemetry — journal counters (trace lines + status widget when tracing).
|
|
117
117
|
let xiCompositions = 0;
|
|
118
118
|
let rootDigests = 0;
|
|
119
119
|
let elidedMessages = 0;
|
|
120
120
|
let sigmaSplices = 0;
|
|
121
|
+
let idleDegrades = 0;
|
|
122
|
+
/** R6: Σ counter snapshot for the status line — a fresh readonly object per render. */
|
|
123
|
+
const sigmaTelemetry = (): RootSigmaTelemetry => ({
|
|
124
|
+
xiCompositions,
|
|
125
|
+
rootDigests,
|
|
126
|
+
elidedMessages,
|
|
127
|
+
sigmaSplices,
|
|
128
|
+
idleDegrades,
|
|
129
|
+
});
|
|
121
130
|
// A detached child works in its OWN sandbox, so this one sees no frames and its request
|
|
122
131
|
// watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
|
|
123
132
|
// with it. Keep it alive while detached work is genuinely in flight.
|
|
@@ -162,7 +171,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
162
171
|
/** Ξ (Workstream C): BM25 slice of the SkillState store for a query; undefined when off. */
|
|
163
172
|
const composeSkillBlock = (query: string): string | undefined => {
|
|
164
173
|
const cfg = controller.config;
|
|
165
|
-
|
|
174
|
+
// R0: enableSkillState is enforced (validateEnforcedOn) — no config check remains.
|
|
175
|
+
if (skillStore === undefined) return undefined;
|
|
166
176
|
const block = skillStore.blockFor(query, cfg.skillStateMaxTokens);
|
|
167
177
|
return block === "" ? undefined : block;
|
|
168
178
|
};
|
|
@@ -204,10 +214,10 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
204
214
|
controller.savedLlmRef = persisted.llm ?? undefined;
|
|
205
215
|
controller.savedRlmRef = persisted.rlm ?? undefined;
|
|
206
216
|
|
|
207
|
-
// SKILL.state (Workstream B): hydrate the cross-session note store
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
217
|
+
// SKILL.state (Workstream B / R0): hydrate the cross-session note store UNCONDITIONALLY —
|
|
218
|
+
// the SkillStore is operating law; no rlm.json, command, or UI path can prevent its birth
|
|
219
|
+
// (hostile configs are traced + ignored at the validateEnforcedOn seam; fail-soft).
|
|
220
|
+
skillStore = await SkillStore.hydrate(controller.config.skillStateNotesPerProject);
|
|
211
221
|
controller.skillStore = skillStore;
|
|
212
222
|
|
|
213
223
|
// An explicit --rlm flag wins over the persisted setting for this session.
|
|
@@ -336,7 +346,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
336
346
|
}
|
|
337
347
|
}
|
|
338
348
|
|
|
339
|
-
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
349
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage(), sigmaTelemetry());
|
|
340
350
|
if (!treePanelInstalled) {
|
|
341
351
|
treePanelInstalled = true;
|
|
342
352
|
installTreePanel(ctx, runRegistry);
|
|
@@ -349,7 +359,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
349
359
|
|
|
350
360
|
// ── Keep the footer's context reading live (RLM exists to shrink this number) ──
|
|
351
361
|
pi.on("turn_end", async (_event, ctx) => {
|
|
352
|
-
setRlmModeStatus(ctx, controller, ctx.getContextUsage());
|
|
362
|
+
setRlmModeStatus(ctx, controller, ctx.getContextUsage(), sigmaTelemetry());
|
|
353
363
|
});
|
|
354
364
|
|
|
355
365
|
/** True when the native-mode trade holds: enabled AND repl is in the active tool set. */
|
|
@@ -387,21 +397,56 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
387
397
|
const message = observation === undefined
|
|
388
398
|
? undefined
|
|
389
399
|
: { customType: "rlm-sigma-observation", content: observation, display: false, details: undefined };
|
|
400
|
+
// R1 + R3 soak instrumentation: what the plugin RETURNS as the system prompt — the host
|
|
401
|
+
// applies result.systemPrompt verbatim (agent-session.js), so this line + the journal
|
|
402
|
+
// decide "wiring bug vs model non-compliance" for the fence contract.
|
|
403
|
+
const nativePrompt = buildNativeSystemPrompt({ stateFences: controller.config.enableRootStateFences });
|
|
404
|
+
if (traceEnabled) {
|
|
405
|
+
trace("root-prompt.composed", {
|
|
406
|
+
chars: nativePrompt.length,
|
|
407
|
+
fences: controller.config.enableRootStateFences,
|
|
408
|
+
contract: nativePrompt.includes("[state] Alongside"),
|
|
409
|
+
xi: xi !== undefined,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
390
412
|
return {
|
|
391
413
|
...(message === undefined ? {} : { message }),
|
|
392
|
-
systemPrompt: event.systemPrompt + "\n\n" + xiPart +
|
|
414
|
+
systemPrompt: event.systemPrompt + "\n\n" + xiPart + nativePrompt,
|
|
393
415
|
};
|
|
394
416
|
});
|
|
395
417
|
|
|
396
|
-
// Root Σ WS-4.2
|
|
397
|
-
// replies and run them through the ONE patch validator (run-state.ts applyPatch).
|
|
418
|
+
// Root Σ WS-4.2 + v2 R4: capture model-proposed ΔΣ_t fences from finalized assistant
|
|
419
|
+
// replies and run them through the ONE patch validator (run-state.ts applyPatch). EVERY
|
|
420
|
+
// assistant turn feeds the ladder — a fence-free turn grows the idle streak, and
|
|
421
|
+
// RUN_STATE_IDLE_DEGRADE_TURNS consecutive idle turns degrade the tracker (G6 parity).
|
|
398
422
|
pi.on("message_end", async (event) => {
|
|
399
423
|
const tracker = rootTracker;
|
|
400
424
|
if (tracker === undefined || !controller.config.enableRootStateFences) return;
|
|
401
425
|
if (event.message.role !== "assistant") return;
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
426
|
+
const wasActive = tracker.isActive;
|
|
427
|
+
const outcome = tracker.applyFences(findStatePatches(agentMessageText(event.message)));
|
|
428
|
+
// R3 soak observability: per-turn fence outcomes — the soak-B bars (≥50% of turns commit
|
|
429
|
+
// ≥1 accepted delta, rejection storms <10%) are computed from these journal lines.
|
|
430
|
+
if (traceEnabled) {
|
|
431
|
+
trace("root-state.turn", {
|
|
432
|
+
...outcome,
|
|
433
|
+
idle: tracker.idleTurns,
|
|
434
|
+
active: tracker.isActive,
|
|
435
|
+
degraded: wasActive && !tracker.isActive,
|
|
436
|
+
// R7-fix: recovery observability — a degraded tracker that accepted a clean batch.
|
|
437
|
+
recovered: !wasActive && tracker.isActive,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
if (wasActive && !tracker.isActive) {
|
|
441
|
+
idleDegrades += 1;
|
|
442
|
+
if (traceEnabled) {
|
|
443
|
+
const reason = tracker.degradeReason ?? "unknown";
|
|
444
|
+
trace(reason.startsWith("idle") ? "root-state.idle-degrade" : "root-state.degrade", {
|
|
445
|
+
idleTurns: tracker.idleTurns,
|
|
446
|
+
reason,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
405
450
|
});
|
|
406
451
|
|
|
407
452
|
// ── Root Σ WS-2: deterministic root compaction (no summary LLM call) ──
|
|
@@ -460,8 +505,22 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
460
505
|
});
|
|
461
506
|
elidedMessages += elided;
|
|
462
507
|
const tracker = rootTracker;
|
|
463
|
-
|
|
464
|
-
|
|
508
|
+
// R4 REV (amnesia fix): degrade suspends fence WRITES (applyFences gate) — never Σ
|
|
509
|
+
// READBACK. SKILL.state §5.3 makes elision lossless precisely because Σ rides with
|
|
510
|
+
// the elided turns; stubbing history while withholding Σ amnesia-loops the agent
|
|
511
|
+
// (announce-continue-then-stop). Splice whenever Σ has content, active or degraded;
|
|
512
|
+
// the fence CONTRACT rides only while active — a degraded tracker ignores fences
|
|
513
|
+
// (applyFences early-returns), so teaching the contract is pure tax.
|
|
514
|
+
if (
|
|
515
|
+
controller.config.rootContextSnapshot && tracker !== undefined &&
|
|
516
|
+
!tracker.isEmpty
|
|
517
|
+
) {
|
|
518
|
+
spliceSigmaSnapshot(filtered, tracker.snapshot(), tracker.rectifyHint(), {
|
|
519
|
+
// R7-fix: teach the contract while DEGRADED too — it is the only road back.
|
|
520
|
+
// Recovery is a clean fence; hiding the notation after degrade made the
|
|
521
|
+
// amnesia window permanent (the fence turns that taught it get elided).
|
|
522
|
+
withContract: controller.config.enableRootStateFences,
|
|
523
|
+
});
|
|
465
524
|
sigmaSplices += 1;
|
|
466
525
|
}
|
|
467
526
|
if (traceEnabled && (elided > 0 || sigmaSplices > 0)) {
|
|
@@ -554,8 +613,9 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
554
613
|
pi.on("session_shutdown", async () => {
|
|
555
614
|
// Root Σ WS-4 harvest symmetry: the root tracker teaches the store exactly like engine
|
|
556
615
|
// runs — the ONE notesFromRunState path — then the existing flush persists everything.
|
|
616
|
+
// R0: enableSkillState is enforced; no config check remains on this path.
|
|
557
617
|
const tracker = rootTracker;
|
|
558
|
-
if (skillStore !== undefined && tracker !== undefined && tracker.dirty
|
|
618
|
+
if (skillStore !== undefined && tracker !== undefined && tracker.dirty) {
|
|
559
619
|
try {
|
|
560
620
|
skillStore.merge(notesFromRunState(tracker.snapshot()));
|
|
561
621
|
if (traceEnabled) trace("root-harvest.merged", { notes: tracker.snapshot().verifiedFacts.length });
|
package/src/prompts/glossary.ts
CHANGED
|
@@ -62,6 +62,11 @@ const SKILL_SEARCH_GLOSSARY_LINES: readonly string[] = Object.freeze([
|
|
|
62
62
|
export const SKILL_RECALL_LINE =
|
|
63
63
|
"Recall more anytime inside repl: `skill_search(query, k=8)` → [{id, text, tags, score}].";
|
|
64
64
|
|
|
65
|
+
/** R5 (G4, /tmp/ROOT_FULL_SKILLSTATE_PLAN.md): the one-line replacement for assistant prose
|
|
66
|
+
* older than the keep window — durable facts live in Σ, the full text in the session log. */
|
|
67
|
+
export const ROOT_TURN_ELIDED_LINE =
|
|
68
|
+
"… turn elided — durable facts live in Σ; full text in session log";
|
|
69
|
+
|
|
65
70
|
export function skillStateLines(noteCount: number, body: string): string {
|
|
66
71
|
return [
|
|
67
72
|
`[Project facts — SkillState, ${noteCount} note${noteCount === 1 ? "" : "s"}, distilled from prior sessions]`,
|
package/src/prompts/native.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
DEFAULT_PROMPT_CAP,
|
|
13
13
|
promptCapTokensK,
|
|
14
14
|
} from "./glossary.ts";
|
|
15
|
+
import { STATE_FENCE_INSTRUCTION } from "../core/run-state.ts";
|
|
15
16
|
|
|
16
17
|
/** Adapts the REPL glossary for native mode — agent calls `repl({code})` instead of writing ```repl``` blocks. */
|
|
17
18
|
function nativeReplGlossary(): string {
|
|
@@ -67,8 +68,15 @@ function nativeReplGlossary(): string {
|
|
|
67
68
|
].join("\n");
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
/** Build the native-mode system prompt for the main Pi agent.
|
|
71
|
-
|
|
71
|
+
/** Build the native-mode system prompt for the main Pi agent.
|
|
72
|
+
*
|
|
73
|
+
* R1 (G1, /tmp/ROOT_FULL_SKILLSTATE_PLAN.md): `stateFences` appends the ONE Σ fence contract
|
|
74
|
+
* (STATE_FENCE_INSTRUCTION verbatim — one wording source, engine and native share it) so the
|
|
75
|
+
* native model can author ΔΣ_t through ```state fences. The fence text is STATIC, so it may
|
|
76
|
+
* ride call-time composition; the FLAG decision happens at the call site — NATIVE_PROMPT_STATIC
|
|
77
|
+
* (the frozen module-load snapshot) is composed with no options and stays contract-free.
|
|
78
|
+
*/
|
|
79
|
+
export function buildNativeSystemPrompt(opts?: { readonly stateFences?: boolean }): string {
|
|
72
80
|
return [
|
|
73
81
|
"╔══════════════════════════════════════════════════════════════════╗",
|
|
74
82
|
"║ NATIVE RLM MODE — YOU ARE AN ORCHESTRATOR, NOT A READER ║",
|
|
@@ -162,6 +170,22 @@ export function buildNativeSystemPrompt(): string {
|
|
|
162
170
|
"</rules>",
|
|
163
171
|
"",
|
|
164
172
|
nativeReplGlossary(),
|
|
173
|
+
...(opts?.stateFences === true
|
|
174
|
+
? [
|
|
175
|
+
"",
|
|
176
|
+
STATE_FENCE_INSTRUCTION,
|
|
177
|
+
// Soak-B finding (R3): models ignore the contract when it only speaks headless
|
|
178
|
+
// \u201c```repl block(s)\u201d \u2014 in native mode those are repl({code}) TOOL calls. The engine
|
|
179
|
+
// wording above stays byte-identical (one source); this line maps it 1:1 onto the
|
|
180
|
+
// native tool-call loop so the fence obligation is unambiguous.
|
|
181
|
+
"NATIVE MODE: you emit repl({code}) as TOOL calls, not ```repl text blocks \u2014 the " +
|
|
182
|
+
"contract above maps 1:1 onto this loop. In ANY reply where you learned something " +
|
|
183
|
+
"durable (a path, a fact, a failed approach, the next step), ALSO emit a ```state " +
|
|
184
|
+
'fenced block in that same reply: {"state_patch": {"verifiedFacts[+]": ' +
|
|
185
|
+
'"src/x.ts \u2014 what you just verified"}}. Deltas only; one small patch per turn; ' +
|
|
186
|
+
"never restate unchanged keys.",
|
|
187
|
+
]
|
|
188
|
+
: []),
|
|
165
189
|
].join("\n");
|
|
166
190
|
}
|
|
167
191
|
|
package/src/text/parsing.ts
CHANGED
|
@@ -49,10 +49,55 @@ export type StateFenceResult =
|
|
|
49
49
|
|
|
50
50
|
const STATE_FENCE = /(`{3,})[ \t]*state[ \t]*\r?\n([\s\S]*?)\1/g;
|
|
51
51
|
|
|
52
|
+
/** Tolerant fallback: a payload object whose FIRST key is the patch key, emitted without a
|
|
53
|
+
* (well-formed) fence — soak keeps catching `...report.state {"state_patch": …}}` blobs from
|
|
54
|
+
* small models that mangle the opening backticks. Matched literally so ordinary prose or
|
|
55
|
+
* example JSON never trips the scanner. */
|
|
56
|
+
const BARE_PATCH = /\{"state_patch"\s*:/g;
|
|
57
|
+
|
|
58
|
+
/** String-aware balanced-brace scan from `start` (an index of `{`). Honors string literals and
|
|
59
|
+
* backslash escapes so braces inside JSON strings cannot unbalance the count. Returns the
|
|
60
|
+
* complete object slice, or undefined when braces never balance before EOF. */
|
|
61
|
+
function balancedJsonObject(text: string, start: number): string | undefined {
|
|
62
|
+
let depth = 0;
|
|
63
|
+
let inStr = false;
|
|
64
|
+
let esc = false;
|
|
65
|
+
for (let i = start; i < text.length; i++) {
|
|
66
|
+
const ch = text.charAt(i);
|
|
67
|
+
if (inStr) {
|
|
68
|
+
if (esc) esc = false;
|
|
69
|
+
else if (ch === "\\") esc = true;
|
|
70
|
+
else if (ch === '"') inStr = false;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (ch === '"') inStr = true;
|
|
74
|
+
else if (ch === "{") depth += 1;
|
|
75
|
+
else if (ch === "}") {
|
|
76
|
+
depth -= 1;
|
|
77
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A dangling opener/closer pair after the fence body was mangled (e.g. `.state {…}}` followed
|
|
84
|
+
* by a lone ``` line) — cosmetic residue we scrub alongside the object itself. */
|
|
85
|
+
const ORPHAN_FENCE = /^ {0,3}`{3,}[ \t]*\r?$/gm;
|
|
86
|
+
|
|
87
|
+
/** Bookkeeping transport, never content: remove well-formed ```state fences AND the bare
|
|
88
|
+
* {"state_patch"…} objects models leak when they botch the fence syntax (both directions —
|
|
89
|
+
* parse for Σ harvest, strip for user-visible answers). Order: well-formed fences out first,
|
|
90
|
+
* then the bare-object scan over the remainder can never double-count the same payload. */
|
|
91
|
+
function sansFences(text: string): string {
|
|
92
|
+
return text.replace(STATE_FENCE, "");
|
|
93
|
+
}
|
|
94
|
+
|
|
52
95
|
/**
|
|
53
96
|
* Workstream A: extract ```state fences (model-proposed ΔΣ_t) from a response, in document
|
|
54
|
-
* order. ```repl parsing is untouched — the two fences coexist in one response.
|
|
55
|
-
*
|
|
97
|
+
* order. ```repl parsing is untouched — the two fences coexist in one response. Well-formed
|
|
98
|
+
* fences yield parsed payloads or a parse error (error-as-observation for the retry ladder);
|
|
99
|
+
* malformed-fence payloads are recovered by the tolerant bare-object scanner, so a mangled
|
|
100
|
+
* opening fence never orphans a valid delta.
|
|
56
101
|
*/
|
|
57
102
|
export function findStatePatches(text: string): readonly StateFenceResult[] {
|
|
58
103
|
const out: StateFenceResult[] = [];
|
|
@@ -67,9 +112,50 @@ export function findStatePatches(text: string): readonly StateFenceResult[] {
|
|
|
67
112
|
out.push({ ok: false, error: errorMessage(err) });
|
|
68
113
|
}
|
|
69
114
|
}
|
|
115
|
+
// Tolerant harvest over fence-free remainder (well-formed payloads already taken above).
|
|
116
|
+
const rest = sansFences(text);
|
|
117
|
+
BARE_PATCH.lastIndex = 0;
|
|
118
|
+
while ((m = BARE_PATCH.exec(rest)) !== null) {
|
|
119
|
+
const obj = balancedJsonObject(rest, m.index);
|
|
120
|
+
if (obj === undefined) continue;
|
|
121
|
+
try {
|
|
122
|
+
out.push({ ok: true, value: JSON.parse(obj) as unknown });
|
|
123
|
+
} catch (err: unknown) {
|
|
124
|
+
out.push({ ok: false, error: errorMessage(err) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
70
127
|
return out;
|
|
71
128
|
}
|
|
72
129
|
|
|
130
|
+
/** Strip ```state fences from free text. A Σ fence is bookkeeping, never content — but models
|
|
131
|
+
* that finalize right after a Σ splice tend to echo the fence verbatim as their final output,
|
|
132
|
+
* which leaked raw state JSON into RlmResult.answer (bench graders scored JSON, reports showed
|
|
133
|
+
* bookkeeping). Deterministic scrub on the answer path; parse semantics stay in findStatePatches.
|
|
134
|
+
* Also removes bare {"state_patch"…} objects (mangled-fence leaks), a `state` token glued to
|
|
135
|
+
* preceding prose, and orphan ``` lines left behind by the mangled pair. */
|
|
136
|
+
export function stripStateFences(text: string): string {
|
|
137
|
+
let out = sansFences(text);
|
|
138
|
+
const parts: string[] = [];
|
|
139
|
+
let cursor = 0;
|
|
140
|
+
BARE_PATCH.lastIndex = 0;
|
|
141
|
+
let m: RegExpExecArray | null;
|
|
142
|
+
while ((m = BARE_PATCH.exec(out)) !== null) {
|
|
143
|
+
const obj = balancedJsonObject(out, m.index);
|
|
144
|
+
if (obj === undefined) continue;
|
|
145
|
+
// Glom any immediately-preceding bare `state`/`.state` token (prose like "...report.state {").
|
|
146
|
+
const before = out.slice(cursor, m.index).replace(/\s*(?:\.?state)\s*$/i, "");
|
|
147
|
+
parts.push(before);
|
|
148
|
+
cursor = m.index + obj.length;
|
|
149
|
+
BARE_PATCH.lastIndex = cursor;
|
|
150
|
+
}
|
|
151
|
+
// No bare objects → leave the text exactly as the well-formed pass left it (a lone ```
|
|
152
|
+
// line can be a legitimate unclosed code fence in real content; only scrub residue that
|
|
153
|
+
// our own removal created).
|
|
154
|
+
if (parts.length === 0) return out.trim();
|
|
155
|
+
parts.push(out.slice(cursor));
|
|
156
|
+
return parts.join("").replace(ORPHAN_FENCE, "").trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
73
159
|
/** Truncate REPL stdout for the model's context window (head + tail, with an elision note).
|
|
74
160
|
* `mark` lets callers specialize the wording (root elision cites the session log) while the
|
|
75
161
|
* head/tail math stays the one implementation. */
|
package/src/ui/config-panel.ts
CHANGED
|
@@ -52,7 +52,7 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
|
|
|
52
52
|
item("maxErrors", "Max consecutive errors", config.maxErrors != null ? String(config.maxErrors) : "none", CHOICES.maxErrors, "Stop after this many consecutive failing turns; none disables the guard."),
|
|
53
53
|
item("orchestrator", "Orchestrator addendum", config.orchestrator ? "on" : "off", CHOICES.orchestrator, "Append extra divide-and-conquer guidance to the root model system prompt."),
|
|
54
54
|
item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
|
|
55
|
-
item("compactionThresholdPct", "Compaction threshold (%)", String(Math.round(config.compactionThresholdPct * 100)), CHOICES.compactionThresholdPct, "
|
|
55
|
+
item("compactionThresholdPct", "Compaction threshold (%)", String(Math.round(config.compactionThresholdPct * 100)), CHOICES.compactionThresholdPct, "DEPRECATED — ignored: compaction uses the absolute 256k ceiling (COMPACTION_CEILING_TOKENS)."),
|
|
56
56
|
item("rootSamplingMaxTokens", "Root model output cap (tok)", String(config.rootSampling?.maxTokens ?? 16384), CHOICES.rootSamplingMaxTokens, "Max output tokens per root-model turn. Lower values keep each turn lean."),
|
|
57
57
|
item("rootSamplingTemperature", "Root sampling temperature", config.rootSampling?.temperature === undefined ? "default" : String(config.rootSampling?.temperature), CHOICES.rootSamplingTemperature,
|
|
58
58
|
"Sampling temperature for RLM root turns, finalize included — 0 = deterministic (the r3 reproducibility setting); 'default' = provider default. Applies to RLM-mode runs, rlm() delegation and child recursion; the native Pi agent loop follows Pi's own session settings."),
|
|
@@ -68,6 +68,18 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
|
|
|
68
68
|
"Allow add_context() to pull an external dir, file, document, or git repo into context."),
|
|
69
69
|
item("autoSeedCwd", "Auto-seed cwd", config.autoSeedCwd ? "on" : "off", CHOICES.autoSeedCwd,
|
|
70
70
|
"Seed the working directory into context on the first repl() call (otherwise starts empty)."),
|
|
71
|
+
// R0 (/tmp/ROOT_FULL_SKILLSTATE_PLAN.md): the SKILL.state / Root Σ paradigm flags are
|
|
72
|
+
// ENFORCED — rendered as a read-only badge so the truth is visible instead of hidden.
|
|
73
|
+
// No toggle exists: applySetting has no case for them and the validator forces true.
|
|
74
|
+
item("__sigma_enforced__", "SKILL.state / Root Σ", "enforced", ["enforced"],
|
|
75
|
+
"ENFORCED (no opt-out): run state, skill state + distill, root context transform, state fences, digest compaction. " +
|
|
76
|
+
"Override attempts in rlm.json are traced (skillstate.override-ignored) and ignored; RLM_BENCH_NO_ROOTCONTEXT=1 is the dev-only measurement hatch."),
|
|
77
|
+
// R5: the window calibrations are rlm.json-only knobs — shown read-only with live values.
|
|
78
|
+
item("__sigma_window__", "Root Σ window (calibration)",
|
|
79
|
+
`keepTurns=${config.rootContextKeepTurns} · elide=${config.rootContextElideChars} · snapshot=${config.rootContextSnapshot ? "on" : "off"}`,
|
|
80
|
+
["rlm.json"],
|
|
81
|
+
"Query-time window calibrations, rlm.json only: rootContextKeepTurns (1 = strict: Σ + current turn; 2 = default), rootContextElideChars, rootContextSnapshot. " +
|
|
82
|
+
"Session resume/fork: the tracker is reborn lazily and Σ re-grows from live observations — the first call after a resume has an empty Σ by design."),
|
|
71
83
|
item("__save__", "Save & close", "↵", ["↵"], "Save these settings and close (Esc also saves)."),
|
|
72
84
|
];
|
|
73
85
|
|
package/src/ui/status.ts
CHANGED
|
@@ -3,23 +3,47 @@
|
|
|
3
3
|
* The footer's extension-status row is sanitized to a single line, so the
|
|
4
4
|
* two-model layout lives in a dedicated multi-line widget instead: one line
|
|
5
5
|
* for the mode, one per model lane (llm = leaf sub-calls, rlm = child engines),
|
|
6
|
-
* each with the live context token spend
|
|
6
|
+
* each with the live context token spend — and, when trace mode is on, a Root Σ
|
|
7
|
+
* telemetry line (R6): Ξ compositions, digest compactions, elisions, Σ splices,
|
|
8
|
+
* idle degrades.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import type { ContextUsage, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
12
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
11
13
|
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
14
|
+
import { traceEnabled } from "../util/trace.ts";
|
|
12
15
|
import { formatTokens } from "./theme.ts";
|
|
13
16
|
|
|
14
17
|
const KEY = "rlm";
|
|
15
18
|
|
|
19
|
+
/** R6: Root Σ closure-counter snapshot (fresh readonly object per render). */
|
|
20
|
+
export interface RootSigmaTelemetry {
|
|
21
|
+
readonly xiCompositions: number;
|
|
22
|
+
readonly rootDigests: number;
|
|
23
|
+
readonly elidedMessages: number;
|
|
24
|
+
readonly sigmaSplices: number;
|
|
25
|
+
readonly idleDegrades: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
export function modelLabel(model: Model<Api> | undefined, fallback: string): string {
|
|
17
29
|
return model ? `${model.provider}/${model.id}` : fallback;
|
|
18
30
|
}
|
|
19
31
|
|
|
32
|
+
/** R6: the Σ telemetry line — built only when tracing and at least one counter is live. */
|
|
33
|
+
function sigmaLine(telemetry: RootSigmaTelemetry): string | undefined {
|
|
34
|
+
const parts: string[] = [];
|
|
35
|
+
if (telemetry.xiCompositions > 0) parts.push(`Ξ${telemetry.xiCompositions}`);
|
|
36
|
+
if (telemetry.elidedMessages > 0) parts.push(`elided ${telemetry.elidedMessages}`);
|
|
37
|
+
if (telemetry.sigmaSplices > 0) parts.push(`Σ${telemetry.sigmaSplices}`);
|
|
38
|
+
if (telemetry.rootDigests > 0) parts.push(`digest ${telemetry.rootDigests}`);
|
|
39
|
+
if (telemetry.idleDegrades > 0) parts.push(`degraded ${telemetry.idleDegrades}`);
|
|
40
|
+
return parts.length === 0 ? undefined : ` Σ ${parts.join(" · ")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
export function formatRlmStatusLines(
|
|
21
44
|
controller: RlmController,
|
|
22
45
|
contextUsage?: ContextUsage,
|
|
46
|
+
telemetry?: RootSigmaTelemetry,
|
|
23
47
|
): readonly string[] {
|
|
24
48
|
if (!controller.enabled) return ["○ RLM OFF"];
|
|
25
49
|
const tokens = contextUsage?.tokens;
|
|
@@ -28,14 +52,25 @@ export function formatRlmStatusLines(
|
|
|
28
52
|
const llmSuffix = controller.config.subSampling.reasoning ? `:${controller.config.subSampling.reasoning}` : "";
|
|
29
53
|
const rlm = modelLabel(controller.rlmModel, controller.savedRlmRef ?? "session");
|
|
30
54
|
const rlmSuffix = controller.config.rootSampling?.reasoning ? `:${controller.config.rootSampling.reasoning}` : "";
|
|
31
|
-
|
|
55
|
+
const lines = [
|
|
32
56
|
"● RLM ON",
|
|
33
57
|
` llm=${llm}${llmSuffix}${tokSuffix}`,
|
|
34
58
|
` rlm=${rlm}${rlmSuffix}${tokSuffix}`,
|
|
35
59
|
];
|
|
60
|
+
// R6: counters surface only under trace mode — the default UI stays clean.
|
|
61
|
+
if (traceEnabled && telemetry !== undefined) {
|
|
62
|
+
const sigma = sigmaLine(telemetry);
|
|
63
|
+
if (sigma !== undefined) lines.push(sigma);
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
36
66
|
}
|
|
37
67
|
|
|
38
68
|
/** Set the above-editor status widget. Idempotent — call on every state change. */
|
|
39
|
-
export function setRlmModeStatus(
|
|
40
|
-
ctx
|
|
69
|
+
export function setRlmModeStatus(
|
|
70
|
+
ctx: ExtensionContext,
|
|
71
|
+
controller: RlmController,
|
|
72
|
+
contextUsage?: ContextUsage,
|
|
73
|
+
telemetry?: RootSigmaTelemetry,
|
|
74
|
+
): void {
|
|
75
|
+
ctx.ui.setWidget(KEY, [...formatRlmStatusLines(controller, contextUsage, telemetry)], { placement: "aboveEditor" });
|
|
41
76
|
}
|
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Nothing is ever hidden: every sub-call renders as its own row (parity with
|
|
8
8
|
* pi, which shows each concurrent tool call individually) — except runs of
|
|
9
|
-
* IDENTICAL sibling leaves (same label+model+status), which
|
|
10
|
-
* expandable "label ×N" group row so a 20-item llm_batch is
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* CONSECUTIVE IDENTICAL sibling leaves (same label+model+status), which
|
|
10
|
+
* collapse into one expandable "label ×N" group row so a 20-item llm_batch is
|
|
11
|
+
* one line, not 20 and a wholesale batch failure is one `✗ label ×N` line.
|
|
12
|
+
* Errors group exactly like successes; distinct failures keep their own rows
|
|
13
|
+
* and reasons, and singletons render as plain rows. Interleaved siblings (✗ ✓
|
|
14
|
+
* ✗ with different keys between) stay in encounter order — position is never
|
|
15
|
+
* rewritten. Collapsed subtrees are skipped at the user's explicit request
|
|
16
|
+
* (chevron flips). Token rows are own-spend only — a row never blends models.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
import type { RlmSubcall, RlmRunStatus, SubcallPhase, SubcallStatus } from "../../tool/rlm-details.ts";
|
|
@@ -81,9 +84,13 @@ type Entry =
|
|
|
81
84
|
| { readonly type: "node"; readonly sc: RlmSubcall }
|
|
82
85
|
| { readonly type: "group"; readonly key: string; readonly label: string; readonly model?: string; readonly status: SubcallStatus; readonly members: RlmSubcall[] };
|
|
83
86
|
|
|
84
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Any childless llm leaf may group — errors included. groupKey pins status, so
|
|
89
|
+
* only runs of identical failures merge; per-item reasons stay in the detail
|
|
90
|
+
* modal (expand the group).
|
|
91
|
+
*/
|
|
85
92
|
const groupable = (sc: RlmSubcall, byParent: ReadonlyMap<string | undefined, RlmSubcall[]>): boolean =>
|
|
86
|
-
sc.kind === "llm" &&
|
|
93
|
+
sc.kind === "llm" && (byParent.get(sc.id)?.length ?? 0) === 0;
|
|
87
94
|
|
|
88
95
|
const groupKey = (sc: RlmSubcall): string => `${sc.label}|${sc.model ?? ""}|${sc.status}`;
|
|
89
96
|
|