@hicaru/pi-rlm 0.3.20 → 0.3.21
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/package.json +1 -1
- package/src/commands/rlm.ts +14 -7
- package/src/config/defaults.ts +33 -10
- package/src/config/settings.ts +6 -0
- package/src/config/skillstate.ts +236 -44
- package/src/core/budget.ts +7 -3
- package/src/core/compaction.ts +2 -2
- package/src/core/engine.ts +87 -19
- package/src/core/root-context.ts +74 -21
- package/src/core/root-digest.ts +48 -11
- package/src/core/root-state.ts +39 -12
- package/src/core/run-state.ts +86 -14
- package/src/core/session-archive.ts +174 -0
- package/src/core/types.ts +6 -0
- package/src/index.ts +142 -12
- package/src/mode/rlm-mode.ts +2 -2
- package/src/prompts/glossary.ts +34 -5
- package/src/prompts/native.ts +8 -2
- package/src/prompts/user.ts +4 -3
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/retrieval.py +202 -36
- package/src/sandbox/py/scaffold.py +20 -5
- package/src/sandbox/py/worker.py +1 -1
- package/src/sandbox/sandbox-manager.ts +19 -0
- package/src/text/parsing.ts +133 -2
- package/src/text/tokens.ts +39 -4
- package/src/tool/repl-render.ts +38 -2
- package/src/tool/repl-tool.ts +34 -18
- package/src/tool/subcall-render.ts +7 -4
- package/src/ui/config-panel.ts +2 -2
- package/src/ui/intro.ts +1 -1
- package/src/ui/python-highlight.ts +49 -0
- package/src/ui/stage-cards.ts +192 -0
- package/src/ui/tree/tree-model.ts +69 -19
- package/src/ui/tree/tree-rows.ts +2 -1
- package/src/util/abort.ts +34 -0
- package/src/util/bm25.ts +170 -21
- package/src/util/errors.ts +1 -1
package/src/core/engine.ts
CHANGED
|
@@ -26,11 +26,11 @@ 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, stripStateFences } from "../text/parsing.ts";
|
|
29
|
+
import { finalVarRepairCode, findFinalTag, findReplBlocks, stripStateFences } from "../text/parsing.ts";
|
|
30
30
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
31
31
|
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, latestStdoutOf, turnHadError } from "./answer.ts";
|
|
32
32
|
import { compactHistory, elideOldToolPayloads, rebaseWithState, shouldCompact } from "./compaction.ts";
|
|
33
|
-
import { applyStatePatches, freshRunState, runStateTurnBlock, type RunState, type RunStateMode } from "./run-state.ts";
|
|
33
|
+
import { applyStatePatches, compactJSON, freshRunState, runStateTurnBlock, SIGMA_UNCHANGED_LINE, type RunState, type RunStateMode } from "./run-state.ts";
|
|
34
34
|
import { findStatePatches } from "../text/parsing.ts";
|
|
35
35
|
import { complete1, completeDeps } from "../bridge/handlers/completion.ts";
|
|
36
36
|
import type { SubcallHandlerDeps } from "../bridge/handlers/types.ts";
|
|
@@ -202,12 +202,27 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
202
202
|
if (detachedInFlight === 0) detachedIdle?.();
|
|
203
203
|
}
|
|
204
204
|
},
|
|
205
|
-
// SKILL.state: leaf grounding (Workstream D, DRY #1) + the
|
|
205
|
+
// SKILL.state: leaf grounding (Workstream D, DRY #1) + the Ξ block for children (C, DRY #6).
|
|
206
206
|
groundLeaf:
|
|
207
207
|
skillStore === undefined || !deps.config.enableSkillState
|
|
208
208
|
? undefined
|
|
209
209
|
: (prompt: string) => groundLeafPrompt(skillStore, deps.config, prompt),
|
|
210
|
-
|
|
210
|
+
// The child's Ξ is re-ranked against the CHILD's task from the live store — inheriting
|
|
211
|
+
// the parent's block verbatim wasted the Ξ budget on facts ranked for someone else's
|
|
212
|
+
// question. Falls back to the inherited block when re-ranking comes up empty (or for
|
|
213
|
+
// the degenerate same-task call), and to `undefined` semantics when the store is off.
|
|
214
|
+
getSkillBlock:
|
|
215
|
+
skillStore === undefined || !deps.config.enableSkillState
|
|
216
|
+
? () => input.skillBlock
|
|
217
|
+
: (task: string): string | undefined => {
|
|
218
|
+
if (task === input.rootPrompt || task.trim() === "") return input.skillBlock;
|
|
219
|
+
const block = skillStore.blockFor(
|
|
220
|
+
task,
|
|
221
|
+
deps.config.skillStateMaxTokens,
|
|
222
|
+
deps.config.skillStateXiMinScore,
|
|
223
|
+
);
|
|
224
|
+
return block !== "" ? block : input.skillBlock;
|
|
225
|
+
},
|
|
211
226
|
} satisfies SubcallHandlerDeps;
|
|
212
227
|
const subcalls = createSubcallHandlers(subcallDeps, taskRegistry);
|
|
213
228
|
|
|
@@ -220,8 +235,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
220
235
|
// off (one source, two sinks; never a second harvest implementation).
|
|
221
236
|
deps.onRunState?.(runStateMode.state);
|
|
222
237
|
if (skillStore === undefined || !deps.config.enableSkillState) return;
|
|
223
|
-
|
|
238
|
+
// Depth-tagged deterministic harvest runs at every depth; the LLM distill is ROOT-ONLY —
|
|
239
|
+
// every child distilling into the same project section crowded the note cap with
|
|
240
|
+
// sub-task fragments (A-Mem store hygiene). Fail-soft: a distill failure must never
|
|
241
|
+
// damage a finished run.
|
|
242
|
+
skillStore.merge(notesFromRunState(runStateMode.state, input.depth));
|
|
224
243
|
if (!deps.config.enableSkillStateDistill || deps.signal?.aborted === true) return;
|
|
244
|
+
if (input.depth > 0) return;
|
|
225
245
|
try {
|
|
226
246
|
const raw = await complete1(
|
|
227
247
|
invocation,
|
|
@@ -229,7 +249,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
229
249
|
() => {},
|
|
230
250
|
completeDeps(subcallDeps),
|
|
231
251
|
);
|
|
232
|
-
const parsed = parseDistilledNotes(raw);
|
|
252
|
+
const parsed = parseDistilledNotes(raw, runStateMode.state.task);
|
|
233
253
|
if (parsed.length > 0) skillStore.merge(parsed);
|
|
234
254
|
} catch {
|
|
235
255
|
// fail-soft by design
|
|
@@ -281,6 +301,11 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
281
301
|
let softNoteTurn = -1;
|
|
282
302
|
// H3: retrieval-discipline coach — one-shot per run; children inherit it via the same loop.
|
|
283
303
|
let sawRetrieval = false;
|
|
304
|
+
let sawDelegation = false;
|
|
305
|
+
// Σ economics: the compact JSON of the Σ last sent to the model — an unchanged Σ
|
|
306
|
+
// re-sends as SIGMA_UNCHANGED_LINE instead of the full block. Reset by compaction/rebase
|
|
307
|
+
// (the rebase message re-anchors Σ verbatim) and by degrade (no more Σ blocks at all).
|
|
308
|
+
let lastSigmaSent: string | undefined;
|
|
284
309
|
let retrievalNudged = false;
|
|
285
310
|
// Verification-discipline coach (enableVerificationNudge, default OFF): one coached redo
|
|
286
311
|
// when an early finalize looks like the confident-wrong bench shape.
|
|
@@ -368,8 +393,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
368
393
|
history = elideOldToolPayloads(history);
|
|
369
394
|
const compactionDeps = {
|
|
370
395
|
// Summarisation is done by the cheap worker model; compaction fires on the ABSOLUTE
|
|
371
|
-
// COMPACTION_CEILING_TOKENS (limits.ts): ≤
|
|
372
|
-
// compact exactly at
|
|
396
|
+
// COMPACTION_CEILING_TOKENS (limits.ts): ≤1M windows never compact, larger ones
|
|
397
|
+
// compact exactly at 1M (LO rule 2025-09-09).
|
|
373
398
|
model: deps.llmModel,
|
|
374
399
|
registry: deps.registry,
|
|
375
400
|
contextWindow: model.contextWindow,
|
|
@@ -382,6 +407,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
382
407
|
history = runStateMode.kind === "active"
|
|
383
408
|
? rebaseWithState(history, runStateMode.state, ++compactions)
|
|
384
409
|
: await compactHistory(history, compactionDeps, ++compactions, (u) => limits.addUsage(u));
|
|
410
|
+
lastSigmaSent = undefined; // the rebase message re-anchors Σ — full block next turn
|
|
385
411
|
}
|
|
386
412
|
}
|
|
387
413
|
|
|
@@ -401,15 +427,22 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
401
427
|
|
|
402
428
|
// v5 [ledger] blackboard — silent ("") when it has nothing to say.
|
|
403
429
|
const ledgerBlock = deps.config.enableLedger ? runLedger.injectBlock() : "";
|
|
430
|
+
const sigmaJson = runStateMode.kind === "active" ? compactJSON(runStateMode.state) : "";
|
|
404
431
|
// H3: after two retrieval-free turns, inject the coach nudge exactly once, for one turn.
|
|
405
|
-
|
|
432
|
+
// Surface-aware: delegation children (depth > 0) have NO search/grep_context — nudging
|
|
433
|
+
// them taught a tool their prompt elsewhere forbids (NameError bait).
|
|
434
|
+
const nudgeNow = input.depth === 0 && i >= 2 && !sawRetrieval && !retrievalNudged;
|
|
406
435
|
if (nudgeNow) retrievalNudged = true;
|
|
407
436
|
const notes =
|
|
408
437
|
[
|
|
409
438
|
i === softNoteTurn ? WRAP_UP_BUDGET : undefined,
|
|
410
439
|
// Workstream A: from iteration 3 the run conditions on Σ (A_t = (P, Σ_t, O_t)) and
|
|
411
440
|
// the state fence is requested — exploratory cold-start stays as-built (§12.2).
|
|
412
|
-
|
|
441
|
+
// Σ economics: an unchanged Σ re-sends as a one-line marker, not the full JSON —
|
|
442
|
+
// full re-send happens only after compaction/rebase (lastSigmaSent reset below).
|
|
443
|
+
runStateMode.kind === "active" && i >= 2
|
|
444
|
+
? (sigmaJson === lastSigmaSent ? SIGMA_UNCHANGED_LINE : runStateTurnBlock(runStateMode.state))
|
|
445
|
+
: undefined,
|
|
413
446
|
ledgerBlock === "" ? undefined : ledgerBlock,
|
|
414
447
|
nudgeNow ? RETRIEVAL_NUDGE : undefined,
|
|
415
448
|
verificationNudgePending ? VERIFICATION_NUDGE : undefined,
|
|
@@ -424,6 +457,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
424
457
|
.filter((s): s is string => s !== undefined)
|
|
425
458
|
.join("\n\n") || undefined;
|
|
426
459
|
verificationNudgePending = false;
|
|
460
|
+
if (runStateMode.kind === "active" && i >= 2) lastSigmaSent = sigmaJson;
|
|
427
461
|
appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, notes));
|
|
428
462
|
|
|
429
463
|
const turn = await runTurn(history, sandbox, {
|
|
@@ -439,6 +473,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
439
473
|
onPhase: reportPhase,
|
|
440
474
|
});
|
|
441
475
|
if (turn.blocks.some((b) => /\b(?:search|grep_context)\s*\(/.test(b))) sawRetrieval = true;
|
|
476
|
+
if (turn.blocks.some((b) => /\b(?:llm_query|llm_batch|rlm_query|rlm_batch|map_files|llm_query_chunked|llm_map_reduce|spawn)\s*\(/.test(b))) sawDelegation = true;
|
|
442
477
|
const allBlocks = turn.blocks.length > 0
|
|
443
478
|
? turn.blocks.map((b) => previewText(b, 400)).join("\n")
|
|
444
479
|
: previewText(turn.response, 400);
|
|
@@ -465,13 +500,33 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
465
500
|
const turnStdout = latestStdoutOf(turn.results);
|
|
466
501
|
if (turnStdout) lastStdout = turnStdout;
|
|
467
502
|
completedTurns = i + 1;
|
|
468
|
-
|
|
503
|
+
let final = finalAnswerOf(turn.results);
|
|
504
|
+
// RLM-paper App. A template repair: a finalize written as FINAL(x) / FINAL_VAR(v)
|
|
505
|
+
// instead of an `answer` flip is converted (16%/13% of small-model turns in the
|
|
506
|
+
// paper). The repair note rides the next observation so the model learns the channel.
|
|
507
|
+
let repairNote: string | undefined;
|
|
508
|
+
if (final == null && sandbox !== undefined) {
|
|
509
|
+
const tag = findFinalTag(turn.response);
|
|
510
|
+
if (tag?.kind === "final") {
|
|
511
|
+
final = tag.value;
|
|
512
|
+
repairNote = "[runtime] Converted FINAL(...) from your prose into the final answer — flip `answer[\"ready\"]` directly next time.";
|
|
513
|
+
} else if (tag?.kind === "final_var") {
|
|
514
|
+
const repaired = await sandbox.exec(finalVarRepairCode(tag.value));
|
|
515
|
+
const recovered = finalAnswerOf([repaired]);
|
|
516
|
+
if (recovered !== null && !recovered.startsWith("Error:")) {
|
|
517
|
+
final = recovered;
|
|
518
|
+
repairNote = `[runtime] Converted FINAL_VAR(${tag.value}) into the final answer — flip \`answer["ready"]\` directly next time.`;
|
|
519
|
+
} else if (recovered !== null) {
|
|
520
|
+
repairNote = recovered;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
469
524
|
if (final != null) {
|
|
470
|
-
// Verification-discipline nudge (
|
|
471
|
-
//
|
|
472
|
-
//
|
|
525
|
+
// Verification-discipline nudge (default ON — bench: 28/33 failures were early
|
|
526
|
+
// confident wrong answers): a finalize that is bare, or arrived without any
|
|
527
|
+
// inspection of the context, gets ONE coached redo, then the answer is accepted.
|
|
473
528
|
if (deps.config.enableVerificationNudge === true && !verificationNudged
|
|
474
|
-
&& completedTurns < VERIFICATION_NUDGE_TURN_CAP && isBareAnswer(final)) {
|
|
529
|
+
&& completedTurns < VERIFICATION_NUDGE_TURN_CAP && isBareAnswer(final, sawRetrieval, sawDelegation)) {
|
|
475
530
|
verificationNudged = true;
|
|
476
531
|
verificationNudgePending = true;
|
|
477
532
|
} else {
|
|
@@ -484,6 +539,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
484
539
|
limits.observe(turnHadError(turn.results));
|
|
485
540
|
history.push({ role: "assistant", content: turn.response });
|
|
486
541
|
pendingReplOutputs = formatReplOutputs(turn.results, turn.skippedBlocks);
|
|
542
|
+
if (repairNote !== undefined) {
|
|
543
|
+
pendingReplOutputs = `${pendingReplOutputs}\n\n${repairNote}`;
|
|
544
|
+
}
|
|
487
545
|
// ── Workstream A: apply ΔΣ_t AFTER the environment reply — Algorithm 1 ordering:
|
|
488
546
|
// state reflects intended effects; feedback arrives as the next O_t. Rejections roll
|
|
489
547
|
// back and lead the next observation (error-as-observation retry); retries exhausted
|
|
@@ -495,6 +553,9 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
495
553
|
i + 1,
|
|
496
554
|
deps.config,
|
|
497
555
|
i >= 2, // the fence was requested this turn → empty turns count as idle (bench rec #2)
|
|
556
|
+
// Productive-turn parity (root tracker, recall W2): executed work that neither
|
|
557
|
+
// raised nor skipped resets the idle streak — repl progress is progress.
|
|
558
|
+
turn.blocks.length > 0 && !turnHadError(turn.results) && turn.skippedBlocks === 0,
|
|
498
559
|
);
|
|
499
560
|
runStateMode = applied.mode;
|
|
500
561
|
if (applied.observation !== undefined) {
|
|
@@ -639,11 +700,18 @@ function contextWindowOrFallback(model: Model<Api>, registry: ModelContextRegist
|
|
|
639
700
|
return registry.limitFor(`${model.provider}/${model.id}`);
|
|
640
701
|
}
|
|
641
702
|
|
|
642
|
-
/**
|
|
643
|
-
*
|
|
644
|
-
|
|
703
|
+
/** Early-confident answer shape — the target of the verification nudge (28/33 bench failures
|
|
704
|
+
* were early confident wrong answers). Bare when: tiny, ≤3 words, a pure number/date/label,
|
|
705
|
+
* or — strongest signal — the run NEVER inspected its context (no retrieval, no delegation):
|
|
706
|
+
* the answer was then guessed, whatever its length. */
|
|
707
|
+
function isBareAnswer(answer: string, sawRetrieval: boolean, sawDelegation: boolean): boolean {
|
|
645
708
|
const t = answer.trim();
|
|
646
|
-
|
|
709
|
+
if (t.length === 0) return true;
|
|
710
|
+
if (!sawRetrieval && !sawDelegation) return true;
|
|
711
|
+
if (t.length <= 12) return true;
|
|
712
|
+
if (t.split(/\s+/).length <= 3) return true;
|
|
713
|
+
if (/^[-+$(€£¥]?\d+(?:[.,]\d+)*\s*%?$/.test(t)) return true;
|
|
714
|
+
return false;
|
|
647
715
|
}
|
|
648
716
|
|
|
649
717
|
/** Out of turns: ask the model for its best final answer. FINALIZE_PROMPT asks for a fenced
|
package/src/core/root-context.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* array) to honor the zero-extra-allocations rule.
|
|
10
10
|
*
|
|
11
11
|
* - `elideStalePayloads` — discard semantics (paper §5.3): tool payloads older than the
|
|
12
|
-
* keep window become head+tail previews; the full bytes
|
|
12
|
+
* keep window become head+tail previews; the full bytes ride the optional `onElide`
|
|
13
|
+
* sink into the SessionArchive (recall W1) so elision stays dereferenceable — search()
|
|
14
|
+
* over `ctx/session-log/*` recalls what the stub replaced.
|
|
13
15
|
* - `spliceSigmaSnapshot` — the fresh Σ snapshot rides immediately before the last user
|
|
14
16
|
* message, exactly one instance (previous ones are removed — idempotent per call).
|
|
15
17
|
*
|
|
@@ -19,7 +21,13 @@
|
|
|
19
21
|
import type { ContextEvent } from "@earendil-works/pi-coding-agent";
|
|
20
22
|
import type { RunState } from "./run-state.ts";
|
|
21
23
|
import { runStateRootBlock } from "./run-state.ts";
|
|
22
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
ROOT_ELIDE_PREVIEW_MARK,
|
|
26
|
+
ROOT_ELIDE_PREVIEW_MARK_REPL,
|
|
27
|
+
ROOT_TURN_ELIDED_ARCHIVE_LINE,
|
|
28
|
+
ROOT_TURN_ELIDED_LINE,
|
|
29
|
+
ROOT_TURN_ELIDED_PLAIN_LINE,
|
|
30
|
+
} from "../prompts/glossary.ts";
|
|
23
31
|
import { truncateOutput } from "../text/parsing.ts";
|
|
24
32
|
import { textContentOf } from "../text/agent-text.ts";
|
|
25
33
|
|
|
@@ -29,9 +37,43 @@ export type RootMessage = ContextEvent["messages"][number];
|
|
|
29
37
|
export interface ElideOptions {
|
|
30
38
|
readonly keepTurns: number;
|
|
31
39
|
readonly elideChars: number;
|
|
40
|
+
/** When true (SessionArchive wired + enabled), stubs point at the searchable archive;
|
|
41
|
+
* when false they stay honest by promising less (no phantom recovery channel). */
|
|
42
|
+
readonly archiveActive?: boolean;
|
|
32
43
|
}
|
|
33
44
|
|
|
34
|
-
|
|
45
|
+
/** One message the elision is about to destroy — the SessionArchive record shape. */
|
|
46
|
+
export interface ElidedEntry {
|
|
47
|
+
readonly role: "assistant" | "toolResult";
|
|
48
|
+
readonly toolName: string | undefined;
|
|
49
|
+
/** The FULL original text (pre-preview, pre-stub) — the archive exists to hold it. */
|
|
50
|
+
readonly text: string;
|
|
51
|
+
}
|
|
52
|
+
export type ElideSink = (entry: ElidedEntry) => void;
|
|
53
|
+
|
|
54
|
+
const REPL_TOOL = "repl";
|
|
55
|
+
|
|
56
|
+
function elidedLineFor(role: string, toolName: string | undefined, archiveActive: boolean): string {
|
|
57
|
+
if (role === "toolResult" && toolName === REPL_TOOL) return ROOT_TURN_ELIDED_LINE;
|
|
58
|
+
return archiveActive ? ROOT_TURN_ELIDED_ARCHIVE_LINE : ROOT_TURN_ELIDED_PLAIN_LINE;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Normalized `archiveActive` — optional knob, resolved once per elide walk. */
|
|
62
|
+
function archiveFlag(opts: ElideOptions): boolean {
|
|
63
|
+
return opts.archiveActive === true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function previewMarkFor(toolName: string | undefined, archiveActive: boolean): string {
|
|
67
|
+
if (toolName === REPL_TOOL) return ROOT_ELIDE_PREVIEW_MARK_REPL;
|
|
68
|
+
// Recall W1: the archive mark may only promise what the archive actually holds.
|
|
69
|
+
return archiveActive ? ROOT_ELIDE_PREVIEW_MARK : "chars elided";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Narrow the toolName field off a toolResult message (indexed — host evolution safe). */
|
|
73
|
+
function toolNameOf(message: RootMessage): string | undefined {
|
|
74
|
+
const name: unknown = (message as { toolName?: unknown }).toolName;
|
|
75
|
+
return typeof name === "string" ? name : undefined;
|
|
76
|
+
}
|
|
35
77
|
|
|
36
78
|
/**
|
|
37
79
|
* WS-3a: elide stale turns. The newest `keepTurns` assistant turns and the final user message
|
|
@@ -40,12 +82,18 @@ const ELIDE_MARK = "chars elided — repl sandbox persists: print(answers[k]) or
|
|
|
40
82
|
* - recent stale ring (the last `max(keepTurns, 1)` stale turns): toolResult payloads over
|
|
41
83
|
* `elideChars` become head+tail previews (same truncation shape as repl stdout); assistant
|
|
42
84
|
* prose always collapses to the one-line stub.
|
|
43
|
-
* - older still: EVERYTHING (payloads included) becomes the one-line
|
|
44
|
-
*
|
|
85
|
+
* - older still: EVERYTHING (payloads included) becomes the one-line stub — a stale preview
|
|
86
|
+
* per turn would itself accumulate linearly and re-create O(T).
|
|
87
|
+
* When `onElide` is provided, every destroyed message's FULL text is handed to the sink
|
|
88
|
+
* BEFORE the stub replaces it (recall W1: the archive keeps elision dereferenceable).
|
|
45
89
|
* `role:"custom"` messages of the Σ/intro kinds are immune (WS-3b owns them). Mutates the
|
|
46
90
|
* array in place; returns the number of messages elided (telemetry), for zero-cost counters.
|
|
47
91
|
*/
|
|
48
|
-
export function elideStalePayloads(
|
|
92
|
+
export function elideStalePayloads(
|
|
93
|
+
messages: RootMessage[],
|
|
94
|
+
opts: ElideOptions,
|
|
95
|
+
onElide?: ElideSink,
|
|
96
|
+
): number {
|
|
49
97
|
const keepTurns = Math.max(0, Math.floor(opts.keepTurns));
|
|
50
98
|
if (messages.length === 0) return 0;
|
|
51
99
|
// Preview ring: stale turns recent enough to deserve the §5.3 head+tail preview. Scales with
|
|
@@ -55,7 +103,7 @@ export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions):
|
|
|
55
103
|
if (keepTurns === 0) {
|
|
56
104
|
// R5 strict-0: nothing inside the window — Σ + immune customs + the final user message
|
|
57
105
|
// are all that survive verbatim.
|
|
58
|
-
return elideRange(messages, 0, messages.length, opts, previewRing);
|
|
106
|
+
return elideRange(messages, 0, messages.length, opts, previewRing, onElide);
|
|
59
107
|
}
|
|
60
108
|
|
|
61
109
|
// Index of the assistant message that opens the keepTurns-th-from-last turn — everything
|
|
@@ -72,7 +120,7 @@ export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions):
|
|
|
72
120
|
}
|
|
73
121
|
}
|
|
74
122
|
if (tailStart <= 0) return 0; // fewer turns than the window — nothing to elide
|
|
75
|
-
return elideRange(messages, 0, tailStart, opts, previewRing);
|
|
123
|
+
return elideRange(messages, 0, tailStart, opts, previewRing, onElide);
|
|
76
124
|
}
|
|
77
125
|
|
|
78
126
|
/** Custom messages the elision never touches — the Σ snapshot/observation and the intro. */
|
|
@@ -94,6 +142,7 @@ function elideRange(
|
|
|
94
142
|
to: number,
|
|
95
143
|
opts: ElideOptions,
|
|
96
144
|
previewRing: number,
|
|
145
|
+
onElide: ElideSink | undefined,
|
|
97
146
|
): number {
|
|
98
147
|
const lastUser = lastIndexOfRole(messages, "user");
|
|
99
148
|
// Pre-pass: stale assistant indices in [from, to) — a payload's recency is measured by the
|
|
@@ -109,6 +158,8 @@ function elideRange(
|
|
|
109
158
|
if (i === lastUser) continue; // paranoia: the final user message is never touched
|
|
110
159
|
if (m.role === "assistant") {
|
|
111
160
|
staleAssistants -= 1; // turns AFTER this one = count minus itself
|
|
161
|
+
const prose = textContentOf(m.content);
|
|
162
|
+
onElide?.({ role: "assistant", toolName: undefined, text: prose });
|
|
112
163
|
// Provider pairing invariant: Anthropic requires every tool_result to follow the
|
|
113
164
|
// assistant message carrying its tool_use; OpenAI requires each tool/function_call_output
|
|
114
165
|
// to pair with its tool_call/function_call. Eliding the toolCall blocks here orphaned the
|
|
@@ -116,25 +167,31 @@ function elideRange(
|
|
|
116
167
|
const toolCalls = Array.isArray(m.content)
|
|
117
168
|
? (m.content as Array<{ type?: string }>).filter((b) => b?.type === "toolCall")
|
|
118
169
|
: [];
|
|
170
|
+
const line = elidedLineFor("assistant", undefined, archiveFlag(opts));
|
|
119
171
|
const content: unknown[] =
|
|
120
|
-
toolCalls.length > 0
|
|
121
|
-
? [...toolCalls, { type: "text", text: ROOT_TURN_ELIDED_LINE }]
|
|
122
|
-
: [{ type: "text", text: ROOT_TURN_ELIDED_LINE }];
|
|
172
|
+
toolCalls.length > 0 ? [...toolCalls, { type: "text", text: line }] : [{ type: "text", text: line }];
|
|
123
173
|
messages[i] = { ...m, content } as RootMessage;
|
|
124
174
|
elided += 1;
|
|
125
175
|
continue;
|
|
126
176
|
}
|
|
127
177
|
if (m.role !== "toolResult") continue;
|
|
178
|
+
const toolName = toolNameOf(m);
|
|
179
|
+
const fullText = textContentOf(m.content);
|
|
128
180
|
if (staleAssistants >= previewRing) {
|
|
129
181
|
// Deep-stale payload: even the preview would accumulate — collapse to the stub.
|
|
130
|
-
|
|
182
|
+
onElide?.({ role: "toolResult", toolName, text: fullText });
|
|
183
|
+
messages[i] = {
|
|
184
|
+
...m,
|
|
185
|
+
content: [{ type: "text", text: elidedLineFor("toolResult", toolName, archiveFlag(opts)) }],
|
|
186
|
+
} as RootMessage;
|
|
131
187
|
elided += 1;
|
|
132
188
|
continue;
|
|
133
189
|
}
|
|
134
|
-
if (
|
|
190
|
+
if (fullText.length <= opts.elideChars) continue;
|
|
191
|
+
onElide?.({ role: "toolResult", toolName, text: fullText });
|
|
135
192
|
messages[i] = {
|
|
136
193
|
...m,
|
|
137
|
-
content: [{ type: "text", text: previewToolText(
|
|
194
|
+
content: [{ type: "text", text: previewToolText(fullText, opts.elideChars, toolName, archiveFlag(opts)) }],
|
|
138
195
|
} as RootMessage;
|
|
139
196
|
elided += 1;
|
|
140
197
|
}
|
|
@@ -184,12 +241,8 @@ function lastIndexOfRole(messages: readonly RootMessage[], role: "user"): number
|
|
|
184
241
|
return -1;
|
|
185
242
|
}
|
|
186
243
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function previewToolText(message: RootMessage, elideChars: number): string {
|
|
192
|
-
const text = textContentOf((message as { content?: unknown }).content);
|
|
244
|
+
/** Preview truncation moved the FULL text out to the caller first — this shapes what stays. */
|
|
245
|
+
function previewToolText(fullText: string, elideChars: number, toolName: string | undefined, archiveActive: boolean): string {
|
|
193
246
|
// The preview REPLACES the payload, so `elideChars` budgets the WHOLE head+tail result.
|
|
194
|
-
return truncateOutput(
|
|
247
|
+
return truncateOutput(fullText, Math.max(100, elideChars), previewMarkFor(toolName, archiveActive));
|
|
195
248
|
}
|
package/src/core/root-digest.ts
CHANGED
|
@@ -25,9 +25,11 @@ import type { CompactionResult, SessionEntry } from "@earendil-works/pi-coding-a
|
|
|
25
25
|
import type { RlmConfig } from "./types.ts";
|
|
26
26
|
import { DEFAULT_NEXT_STEP, FINDINGS_MAX, FINDINGS_MIN_CHARS, NEXT_STEP_RE, STATE_MAX, truncateMid } from "./budget.ts";
|
|
27
27
|
import { estimateMessageTokens } from "../text/tokens.ts";
|
|
28
|
+
import { bm25Rank } from "../util/bm25.ts";
|
|
28
29
|
import { agentMessageText } from "../text/agent-text.ts";
|
|
29
30
|
import { isRecord } from "../util/type-guards.ts";
|
|
30
31
|
import { ROOT_DIGEST_HEADER, ROOT_DIGEST_SECTIONS } from "../prompts/glossary.ts";
|
|
32
|
+
import { trace, traceEnabled } from "../util/trace.ts";
|
|
31
33
|
|
|
32
34
|
/** Marker persisted on the session's compaction entry — tests and soaks assert on it. */
|
|
33
35
|
export interface RootDigestDetails {
|
|
@@ -106,17 +108,47 @@ function taskSection(messages: readonly unknown[]): string {
|
|
|
106
108
|
return "";
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
/** [Findings] —
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
/** [Findings] — hybrid selection, then chronological render. Candidates keep the dedup
|
|
112
|
+
* discipline (recall W4: exact + 80-char prefix), but the final pick is no longer pure
|
|
113
|
+
* recency: each candidate scores `BM25(task) normalized + recency share`, so load-bearing
|
|
114
|
+
* findings outrank newer rambles while recency breaks ties (and keeps no-overlap corpora
|
|
115
|
+
* byte-compatible with the old newest-first behavior). */
|
|
116
|
+
const FINDING_CANDIDATES_MAX = 40;
|
|
117
|
+
|
|
118
|
+
function findingsSection(messages: readonly unknown[], task: string): readonly string[] {
|
|
119
|
+
const candidates: string[] = [];
|
|
120
|
+
const seenExact = new Set<string>();
|
|
121
|
+
const seenPrefix = new Set<string>();
|
|
122
|
+
for (let i = messages.length - 1; i >= 0 && candidates.length < FINDING_CANDIDATES_MAX; i--) {
|
|
113
123
|
const m = messages[i];
|
|
114
124
|
if (!isRecord(m) || m.role !== "assistant") continue;
|
|
115
125
|
const text = agentMessageText(m).trim();
|
|
116
|
-
if (text.length
|
|
126
|
+
if (text.length <= FINDINGS_MIN_CHARS) continue;
|
|
127
|
+
const exactKey = text.toLowerCase().replace(/\s+/g, " ");
|
|
128
|
+
const prefixKey = exactKey.slice(0, 80);
|
|
129
|
+
if (seenExact.has(exactKey) || seenPrefix.has(prefixKey)) continue;
|
|
130
|
+
seenExact.add(exactKey);
|
|
131
|
+
seenPrefix.add(prefixKey);
|
|
132
|
+
candidates.push(text);
|
|
117
133
|
}
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
if (candidates.length <= FINDINGS_MAX) {
|
|
135
|
+
candidates.reverse();
|
|
136
|
+
return candidates;
|
|
137
|
+
}
|
|
138
|
+
// Hybrid rank: BM25 relevance to the live task + recency share (newest ≈ 1, oldest ≈ 0).
|
|
139
|
+
// `candidates` is collected newest-first, so recency decays with the index.
|
|
140
|
+
const ranked = bm25Rank(task, candidates.map((text) => ({ item: text, text })), candidates.length);
|
|
141
|
+
const relevance = new Map<string, number>();
|
|
142
|
+
const maxRel = ranked[0]?.score ?? 0;
|
|
143
|
+
for (const { item, score } of ranked) relevance.set(item, maxRel > 0 ? score / maxRel : 0);
|
|
144
|
+
const n = candidates.length;
|
|
145
|
+
const scored = candidates.map((text, i) => ({
|
|
146
|
+
text,
|
|
147
|
+
hybrid: (relevance.get(text) ?? 0) + (n - i) / n,
|
|
148
|
+
}));
|
|
149
|
+
scored.sort((a, b) => b.hybrid - a.hybrid);
|
|
150
|
+
const picked = new Set(scored.slice(0, FINDINGS_MAX).map((s) => s.text));
|
|
151
|
+
return candidates.filter((text) => picked.has(text));
|
|
120
152
|
}
|
|
121
153
|
|
|
122
154
|
/** [State] — newest-first toolResult first-lines (the last observed machinery state). */
|
|
@@ -163,10 +195,10 @@ export function buildRootDigestCompaction(
|
|
|
163
195
|
|
|
164
196
|
const max = Math.max(200, args.config.rootDigestMaxChars);
|
|
165
197
|
const task = truncateMid(taskSection(messages), Math.floor(max * TASK_FRACTION));
|
|
166
|
-
const findings = findingsSection(messages).map((f) => truncateMid(f, BULLET_CHARS));
|
|
198
|
+
const findings = findingsSection(messages, task).map((f) => truncateMid(f, BULLET_CHARS));
|
|
167
199
|
const states = stateSection(messages);
|
|
168
200
|
// Next-step probe mirrors distillTrajectory: newest-first scan, chronological render.
|
|
169
|
-
const next = findings.find((f) => NEXT_STEP_RE.test(f)) ?? DEFAULT_NEXT_STEP;
|
|
201
|
+
const next = [...findings].reverse().find((f) => NEXT_STEP_RE.test(f)) ?? DEFAULT_NEXT_STEP;
|
|
170
202
|
const facts = args.store === undefined
|
|
171
203
|
? ""
|
|
172
204
|
: args.store.sliceForPrompt(
|
|
@@ -190,12 +222,17 @@ export function buildRootDigestCompaction(
|
|
|
190
222
|
|
|
191
223
|
// Over-cap drop order: [Project facts] → [State] → [Findings]; [Task]/[Next] survive to a
|
|
192
224
|
// final truncate — task continuity beats trivia (paper §3.1 sufficient statistic).
|
|
225
|
+
// Recall W4: drops are traced (they were invisible before — a fat facts section could
|
|
226
|
+
// silently eat the entire [State] continuity floor).
|
|
227
|
+
let dropped = "";
|
|
193
228
|
let summary = render(facts, states, findings);
|
|
194
|
-
if (summary.length > max) summary = render("", states, findings);
|
|
195
|
-
if (summary.length > max) summary = render("", [], findings);
|
|
229
|
+
if (summary.length > max) { summary = render("", states, findings); dropped = "facts"; }
|
|
230
|
+
if (summary.length > max) { summary = render("", [], findings); dropped += " state"; }
|
|
196
231
|
if (summary.length > max) {
|
|
197
232
|
summary = render("", [], findings.slice(0, Math.max(1, Math.floor(findings.length / 2))));
|
|
233
|
+
dropped += " findings-half";
|
|
198
234
|
}
|
|
235
|
+
if (dropped !== "" && traceEnabled) trace("root-digest.drop", { dropped, max, finalChars: summary.length });
|
|
199
236
|
summary = truncateMid(summary, max);
|
|
200
237
|
|
|
201
238
|
const boundary = cut >= 0 ? args.branchEntries[cut] : undefined;
|
package/src/core/root-state.ts
CHANGED
|
@@ -124,14 +124,17 @@ export class RootStateTracker {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
noteFinding(text: string): void {
|
|
127
|
-
|
|
127
|
+
// Runtime-harvested findings are clamped at the source — fence-sourced ones arrive ≤120
|
|
128
|
+
// via the patch clamp; this is the defense for direct feeds (engine mirrors arrive capped).
|
|
129
|
+
const trimmed = text.trim().slice(0, 300);
|
|
128
130
|
if (trimmed === "") return;
|
|
129
131
|
this.draft.findings = dedupStrings([...this.draft.findings, trimmed]).slice(-RUN_STATE_LIMITS.findings);
|
|
130
132
|
this.touch();
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
noteFact(text: string): void {
|
|
134
|
-
|
|
136
|
+
// Clamp before dedup (recall W2): a verbose runtime feed must not eat the κ_Σ budget.
|
|
137
|
+
const trimmed = text.trim().slice(0, 200);
|
|
135
138
|
if (trimmed === "") return;
|
|
136
139
|
this.draft.verifiedFacts = dedupStrings([...this.draft.verifiedFacts, trimmed])
|
|
137
140
|
.slice(-RUN_STATE_LIMITS.verifiedFacts);
|
|
@@ -150,17 +153,27 @@ export class RootStateTracker {
|
|
|
150
153
|
this.touch();
|
|
151
154
|
}
|
|
152
155
|
|
|
153
|
-
/** Tool-outcome feed (WS-4 v1 source):
|
|
156
|
+
/** Tool-outcome feed (WS-4 v1 source; recall W2 pollution fix): a SINGLE transient failure
|
|
157
|
+
* (binary read, typo'd path) no longer writes a `testedApproaches` record — that record
|
|
158
|
+
* evicted real approach entries and fired spurious [rectify] hints. Failures always grow
|
|
159
|
+
* the rectify counter, but only PROMOTE to Σ once the failure repeats (threshold parity)
|
|
160
|
+
* or a record already exists (then the record refreshes, keeping recency truthful). */
|
|
154
161
|
observeToolResult(toolName: string, isError: boolean, reasonFirstLine: string): void {
|
|
162
|
+
const key = `tool:${toolName}`;
|
|
155
163
|
if (isError) {
|
|
156
|
-
this.
|
|
164
|
+
const streak = (this.failures.get(key) ?? 0) + 1;
|
|
165
|
+
this.failures.set(key, streak);
|
|
166
|
+
if (streak >= RECTIFY_FAILURE_THRESHOLD || this.draft.testedApproaches[key] !== undefined) {
|
|
167
|
+
this.noteOutcome(key, { status: "failed", reason: reasonFirstLine });
|
|
168
|
+
}
|
|
157
169
|
} else {
|
|
158
170
|
// A success clears the tool's failure streak — the state reflects the newest truth.
|
|
159
|
-
if (this.draft.testedApproaches[
|
|
160
|
-
this.
|
|
161
|
-
|
|
162
|
-
this.
|
|
171
|
+
if (this.draft.testedApproaches[key] !== undefined) {
|
|
172
|
+
const failures = this.failures.get(key) ?? 0;
|
|
173
|
+
const evidence = failures > 0 ? `recovered after ${failures} failure(s)` : reasonFirstLine;
|
|
174
|
+
this.noteOutcome(key, { status: "succeeded", evidence });
|
|
163
175
|
}
|
|
176
|
+
this.failures.delete(key);
|
|
164
177
|
}
|
|
165
178
|
}
|
|
166
179
|
|
|
@@ -204,14 +217,27 @@ export class RootStateTracker {
|
|
|
204
217
|
* see the const's soak citation). Any accepted delta resets the streak. In
|
|
205
218
|
* degraded mode fences stop applying and the context transform stops splicing (`isActive`),
|
|
206
219
|
* while runtime `observeToolResult` remains the Σ floor (degrade, never crash).
|
|
220
|
+
*
|
|
221
|
+
* Recall W2 amendments (root-only, engine ladder untouched):
|
|
222
|
+
* - `productiveTurn` — a turn that RAN TOOLS did work; growing the idle streak over it
|
|
223
|
+
* degraded file-editing sessions whose turns legitimately need no fence. Productive
|
|
224
|
+
* turns neither grow nor reset the streak (neutral).
|
|
225
|
+
* - Retry accounting — a batch that ACCEPTED deltas does not accumulate its problems into
|
|
226
|
+
* the session-wide retry counter (real work is never punished for a sibling's malformed
|
|
227
|
+
* fence — the observation still reports them); the counter only grows over zero-progress
|
|
228
|
+
* batches, so the storm cap stays a storm cap, not a session-lifetime budget.
|
|
207
229
|
*/
|
|
208
|
-
applyFences(fences: readonly StateFenceResult[]): FenceOutcome {
|
|
230
|
+
applyFences(fences: readonly StateFenceResult[], opts?: { readonly productiveTurn?: boolean }): FenceOutcome {
|
|
209
231
|
// R7-fix (recoverable degrade): a DEGRADED tracker no longer drops fences on the floor.
|
|
210
232
|
// The old early-return turned idle degrade into a one-way amnesia valve — every later
|
|
211
233
|
// fence vanished silently while the context transform kept eliding turns. Now the same
|
|
212
234
|
// validation ladder runs in degraded mode, and a CLEAN batch re-activates compensation.
|
|
213
235
|
// Degrade stays sticky only against zero-progress storms (all-malformed batches).
|
|
214
236
|
if (fences.length === 0) {
|
|
237
|
+
if (opts?.productiveTurn === true) {
|
|
238
|
+
// Recall W2: tool-running turns are work, not fence idleness — neutral, no degrade.
|
|
239
|
+
return { fences: 0, accepted: 0, problems: 0 };
|
|
240
|
+
}
|
|
215
241
|
// R4 (G6): a fence-free turn on a conditioned loop is IDLE — the contract rode the
|
|
216
242
|
// prompt for nothing. Grow the streak; degrade at the engine's threshold.
|
|
217
243
|
this.idleFenceTurns += 1;
|
|
@@ -247,9 +273,10 @@ export class RootStateTracker {
|
|
|
247
273
|
this.touch();
|
|
248
274
|
return { fences: fences.length, accepted, problems: 0 };
|
|
249
275
|
}
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
276
|
+
// Recall W2: a productive batch (accepted > 0) starts its retry count at zero instead of
|
|
277
|
+
// accumulating the session's history — the storm cap then measures CONSECUTIVE failure
|
|
278
|
+
// storms, not session age. Zero-progress batches accumulate exactly as before.
|
|
279
|
+
const retries = accepted > 0 ? problems.length : (this.mode.kind === "active" ? this.mode.retries : 0) + problems.length;
|
|
253
280
|
this.pendingObservation = statePatchObservation(problems);
|
|
254
281
|
// Accepted deltas in a partially-failing batch still land — engine parity: real work is
|
|
255
282
|
// never rolled back just because a sibling fence was malformed.
|