@hicaru/pi-rlm 0.3.16 → 0.3.17
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 +0 -4
- package/README.ru.md +56 -66
- package/README.zh-CN.md +61 -65
- package/package.json +5 -5
- package/src/bridge/add-context.ts +1 -1
- package/src/bridge/handlers/await.ts +13 -22
- package/src/bridge/handlers/completion.ts +27 -5
- package/src/bridge/handlers/emitting.ts +2 -2
- package/src/bridge/handlers/llm-query.ts +46 -68
- package/src/bridge/handlers/rlm-query.ts +14 -84
- package/src/bridge/handlers/task-registry.ts +22 -17
- package/src/bridge/handlers/types.ts +8 -6
- package/src/bridge/model.ts +6 -3
- package/src/commands/rlm-llm.ts +1 -10
- package/src/commands/rlm-rlm.ts +1 -8
- package/src/config/defaults.ts +28 -12
- package/src/config/settings.ts +41 -31
- package/src/config/skillstate.ts +465 -0
- package/src/context/md-cache.ts +1 -1
- package/src/context/merge.ts +1 -1
- package/src/context/namespace.ts +2 -2
- package/src/context/refresh.ts +1 -1
- package/src/context/source-dir.ts +21 -11
- package/src/context/source-doc.ts +1 -1
- package/src/context/source-git.ts +3 -15
- package/src/context/source-text.ts +1 -1
- package/src/context/walk.ts +6 -14
- package/src/core/budget.ts +107 -21
- package/src/core/compaction.ts +44 -1
- package/src/core/engine.ts +141 -84
- package/src/core/iteration.ts +1 -1
- package/src/core/ledger.ts +10 -13
- package/src/core/limits.ts +1 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/resource-limits.ts +1 -1
- package/src/core/root-context.ts +126 -0
- package/src/core/root-digest.ts +213 -0
- package/src/core/root-state.ts +240 -0
- package/src/core/run-state.ts +577 -0
- package/src/core/types.ts +51 -12
- package/src/index.ts +167 -36
- package/src/mode/llm-model.ts +13 -1
- package/src/mode/native-guards.ts +0 -6
- package/src/mode/rlm-mode.ts +34 -11
- package/src/mode/subagent.ts +5 -5
- package/src/prompts/glossary.ts +41 -25
- package/src/prompts/native.ts +1 -3
- package/src/prompts/system.ts +12 -4
- package/src/sandbox/context-file.ts +1 -1
- package/src/sandbox/interrupts.ts +25 -31
- package/src/sandbox/protocol.ts +14 -20
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/worker.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +1 -1
- package/src/sandbox/py/scaffold.py +24 -31
- package/src/sandbox/py/worker.py +3 -1
- package/src/sandbox/sandbox-manager.ts +2 -2
- package/src/sandbox/sandbox.ts +21 -4
- package/src/text/agent-text.ts +58 -0
- package/src/text/parsing.ts +35 -3
- package/src/text/preview.ts +3 -0
- package/src/text/repl-output.ts +1 -1
- package/src/tool/background-tasks.ts +1 -1
- package/src/tool/repl-render.ts +1 -1
- package/src/tool/repl-result.ts +1 -1
- package/src/tool/repl-tool.ts +50 -26
- package/src/tool/rlm-tool.ts +4 -5
- package/src/tool/subcall-render.ts +1 -1
- package/src/tool/subcall-store.ts +2 -2
- package/src/tool/tool-utils.ts +5 -5
- package/src/ui/intro.ts +1 -1
- package/src/ui/modal/timeline-store.ts +1 -1
- package/src/ui/model-picker/drilldown.ts +1 -1
- package/src/ui/model-picker/levels.ts +1 -1
- package/src/ui/panel/run-registry.ts +1 -1
- package/src/ui/tree/tree-rows.ts +1 -1
- package/src/ui/tree/tree-widget.ts +1 -1
- package/src/util/bm25.ts +97 -0
- package/src/util/concurrency.ts +1 -1
- package/src/util/errors.ts +1 -1
- package/src/util/retry.ts +22 -7
- package/src/util/state-merge.ts +34 -0
- package/src/util/throttle.ts +1 -1
- package/src/util/type-guards.ts +6 -0
- package/src/core/memory.ts +0 -589
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root Σ (WS-2) — deterministic root compaction for the Pi harness orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* When the native session compacts, this replaces Pi's LLM prose summarizer with a
|
|
5
|
+
* structural digest: the same no-LLM distillation shape the engine proven-uses at its
|
|
6
|
+
* budget hard-state (core/budget.ts:distillTrajectory), adapted to host AgentMessages.
|
|
7
|
+
* Zero summary tokens; compaction becomes reproducible. Paper framing: the digest is a
|
|
8
|
+
* Σ-style sufficient statistic (§3.1) — sections are ordered by continuity value, and the
|
|
9
|
+
* verbatim tail after `firstKeptEntryId` stays byte-identical (§5.3 observation override:
|
|
10
|
+
* fresh tool results outrank the digest).
|
|
11
|
+
*
|
|
12
|
+
* Cut-point semantics: Pi's preparation already picked `firstKeptEntryId` via its
|
|
13
|
+
* keepRecentTokens walk. We only ever TIGHTEN (move the cut later, shrinking the verbatim
|
|
14
|
+
* tail toward `rootDigestKeepRecentChars`) — never extend — and fold every message the
|
|
15
|
+
* tighter cut displaces into the digest inputs, so nothing is dropped unaccounted. Cuts
|
|
16
|
+
* land on entry boundaries Pi itself treats as safe (the walk is char-budgeted over whole
|
|
17
|
+
* entries, mirroring pi-docs/compaction.md's accumulate-and-cut).
|
|
18
|
+
*
|
|
19
|
+
* Failure semantics: pure function, no model call, no throw paths — the index.ts handler
|
|
20
|
+
* still wraps it fail-soft (host contract: handlers must not throw) and returns undefined
|
|
21
|
+
* so Pi falls back to its own summarizer.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { CompactionResult, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import type { RlmConfig } from "./types.ts";
|
|
26
|
+
import { DEFAULT_NEXT_STEP, FINDINGS_MAX, FINDINGS_MIN_CHARS, NEXT_STEP_RE, STATE_MAX, truncateMid } from "./budget.ts";
|
|
27
|
+
import { estimateMessageTokens } from "../text/tokens.ts";
|
|
28
|
+
import { agentMessageText } from "../text/agent-text.ts";
|
|
29
|
+
import { isRecord } from "../util/type-guards.ts";
|
|
30
|
+
import { ROOT_DIGEST_HEADER, ROOT_DIGEST_SECTIONS } from "../prompts/glossary.ts";
|
|
31
|
+
|
|
32
|
+
/** Marker persisted on the session's compaction entry — tests and soaks assert on it. */
|
|
33
|
+
export interface RootDigestDetails {
|
|
34
|
+
readonly kind: "root-digest";
|
|
35
|
+
readonly version: 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Structural slice of SkillStore the digest needs — tests inject stand-ins without a store. */
|
|
39
|
+
export interface DigestFactSource {
|
|
40
|
+
sliceForPrompt(query: string, budgetTokens: number, minScore: number): string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `SessionBeforeCompactEvent["preparation"]` slice — indexed on the event shape upstream. */
|
|
44
|
+
export interface RootDigestPreparation {
|
|
45
|
+
readonly firstKeptEntryId: string;
|
|
46
|
+
readonly messagesToSummarize: readonly unknown[];
|
|
47
|
+
readonly isSplitTurn: boolean;
|
|
48
|
+
readonly tokensBefore: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RootDigestArgs {
|
|
52
|
+
readonly preparation: RootDigestPreparation;
|
|
53
|
+
/** Chronological session entries (event.branchEntries) — the tighten walk reads these. */
|
|
54
|
+
readonly branchEntries: readonly SessionEntry[];
|
|
55
|
+
readonly config: Pick<RlmConfig, "rootDigestKeepRecentChars" | "rootDigestMaxChars" | "skillStateMinScore">;
|
|
56
|
+
readonly store: DigestFactSource | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Section caps come from budget.ts (ONE source — N2); only digest-local shapes live here. */
|
|
60
|
+
const BULLET_CHARS = 400;
|
|
61
|
+
const TASK_FRACTION = 0.3;
|
|
62
|
+
const FACTS_FRACTION = 4; // facts budget = maxChars / FACTS_FRACTION, tokens at 4 chars/token
|
|
63
|
+
|
|
64
|
+
/** AgentMessage-shaped projection of one session entry (host sessionEntryToContextMessages shape). */
|
|
65
|
+
function entryMessages(entry: SessionEntry): readonly unknown[] {
|
|
66
|
+
if (!isRecord(entry)) return [];
|
|
67
|
+
if (entry.type === "message" && isRecord(entry.message)) return [entry.message];
|
|
68
|
+
if (entry.type === "custom_message") return [{ role: "custom", content: entry.content }];
|
|
69
|
+
if (entry.type === "compaction" && typeof entry.summary === "string") {
|
|
70
|
+
return [{ role: "compactionSummary", summary: entry.summary }];
|
|
71
|
+
}
|
|
72
|
+
if (entry.type === "branch_summary" && typeof entry.summary === "string") {
|
|
73
|
+
return [{ role: "branchSummary", summary: entry.summary }];
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function entryChars(entry: SessionEntry): number {
|
|
79
|
+
let total = 0;
|
|
80
|
+
for (const message of entryMessages(entry)) total += agentMessageText(message).length + 8;
|
|
81
|
+
return total;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The ONE tighten walk: index of the first entry kept verbatim (≥ piCut). Walks backward
|
|
85
|
+
* accumulating entry chars until the budget is met; when the whole tail fits under the
|
|
86
|
+
* budget, Pi's boundary stands — we never EXTEND the digest span past Pi's cut.
|
|
87
|
+
*/
|
|
88
|
+
function tightenedCut(entries: readonly SessionEntry[], piCut: number, keepChars: number): number {
|
|
89
|
+
let acc = 0;
|
|
90
|
+
for (let i = entries.length - 1; i >= piCut; i--) {
|
|
91
|
+
acc += entryChars(entries[i]);
|
|
92
|
+
if (acc >= keepChars) return i;
|
|
93
|
+
}
|
|
94
|
+
return piCut;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** [Task] — the LAST user prompt in the span (the ask still in force when compaction hits). */
|
|
98
|
+
function taskSection(messages: readonly unknown[]): string {
|
|
99
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
100
|
+
const m = messages[i];
|
|
101
|
+
if (isRecord(m) && m.role === "user") {
|
|
102
|
+
const text = agentMessageText(m).trim();
|
|
103
|
+
if (text !== "") return text;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** [Findings] — newest-first substantive assistant blobs, capped, then chronological. */
|
|
110
|
+
function findingsSection(messages: readonly unknown[]): readonly string[] {
|
|
111
|
+
const findings: string[] = [];
|
|
112
|
+
for (let i = messages.length - 1; i >= 0 && findings.length < FINDINGS_MAX; i--) {
|
|
113
|
+
const m = messages[i];
|
|
114
|
+
if (!isRecord(m) || m.role !== "assistant") continue;
|
|
115
|
+
const text = agentMessageText(m).trim();
|
|
116
|
+
if (text.length > FINDINGS_MIN_CHARS) findings.push(text);
|
|
117
|
+
}
|
|
118
|
+
findings.reverse();
|
|
119
|
+
return findings;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** [State] — newest-first toolResult first-lines (the last observed machinery state). */
|
|
123
|
+
function stateSection(messages: readonly unknown[]): readonly string[] {
|
|
124
|
+
const states: string[] = [];
|
|
125
|
+
for (let i = messages.length - 1; i >= 0 && states.length < STATE_MAX; i--) {
|
|
126
|
+
const m = messages[i];
|
|
127
|
+
if (!isRecord(m) || m.role !== "toolResult") continue;
|
|
128
|
+
const text = agentMessageText(m).trim();
|
|
129
|
+
if (text === "") continue;
|
|
130
|
+
const tool = typeof m.toolName === "string" ? m.toolName : "tool";
|
|
131
|
+
states.push(`${tool}: ${text.split("\n", 1)[0] ?? text}`);
|
|
132
|
+
}
|
|
133
|
+
states.reverse();
|
|
134
|
+
return states;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the digest compaction. Returns undefined when Pi should keep its own path:
|
|
139
|
+
* split turns (our flat summary would double-count the turn prefix) or an empty span.
|
|
140
|
+
*
|
|
141
|
+
* `tokensBeforeRecomputed` is our own estimateMessageTokens pass over the same span —
|
|
142
|
+
* V1 soak probe: the host consumes `compaction.tokensBefore` (Pi's own preparation number)
|
|
143
|
+
* as-is; one trace line carries both so the first real /compact confirms whether the host
|
|
144
|
+
* ever diverges (status-line truth check) without touching the persisted CompactionResult.
|
|
145
|
+
*/
|
|
146
|
+
export function buildRootDigestCompaction(
|
|
147
|
+
args: RootDigestArgs,
|
|
148
|
+
): {
|
|
149
|
+
readonly compaction: CompactionResult<RootDigestDetails>;
|
|
150
|
+
readonly tokensBeforeRecomputed: number;
|
|
151
|
+
} | undefined {
|
|
152
|
+
const prep = args.preparation;
|
|
153
|
+
if (prep.isSplitTurn) return undefined;
|
|
154
|
+
|
|
155
|
+
const piCut = args.branchEntries.findIndex((entry) => entry.id === prep.firstKeptEntryId);
|
|
156
|
+
const cut = piCut < 0 ? -1 : tightenedCut(args.branchEntries, piCut, args.config.rootDigestKeepRecentChars);
|
|
157
|
+
|
|
158
|
+
const messages: unknown[] = [...prep.messagesToSummarize];
|
|
159
|
+
if (cut > piCut && piCut >= 0) {
|
|
160
|
+
for (let i = piCut; i < cut; i++) messages.push(...entryMessages(args.branchEntries[i]));
|
|
161
|
+
}
|
|
162
|
+
if (messages.length === 0) return undefined;
|
|
163
|
+
|
|
164
|
+
const max = Math.max(200, args.config.rootDigestMaxChars);
|
|
165
|
+
const task = truncateMid(taskSection(messages), Math.floor(max * TASK_FRACTION));
|
|
166
|
+
const findings = findingsSection(messages).map((f) => truncateMid(f, BULLET_CHARS));
|
|
167
|
+
const states = stateSection(messages);
|
|
168
|
+
// Next-step probe mirrors distillTrajectory: newest-first scan, chronological render.
|
|
169
|
+
const next = findings.find((f) => NEXT_STEP_RE.test(f)) ?? DEFAULT_NEXT_STEP;
|
|
170
|
+
const facts = args.store === undefined
|
|
171
|
+
? ""
|
|
172
|
+
: args.store.sliceForPrompt(
|
|
173
|
+
task,
|
|
174
|
+
Math.max(1, Math.floor(max / FACTS_FRACTION / 4)),
|
|
175
|
+
args.config.skillStateMinScore,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const render = (factsText: string, stateLines: readonly string[], findingLines: readonly string[]): string => {
|
|
179
|
+
const blocks: string[] = [ROOT_DIGEST_HEADER];
|
|
180
|
+
const section = (label: string, body: string): void => {
|
|
181
|
+
if (body.trim() !== "") blocks.push(`[${label}] ${body}`);
|
|
182
|
+
};
|
|
183
|
+
section(ROOT_DIGEST_SECTIONS.task, task);
|
|
184
|
+
section(ROOT_DIGEST_SECTIONS.findings, findingLines.map((f) => `- ${f}`).join("\n"));
|
|
185
|
+
section(ROOT_DIGEST_SECTIONS.state, stateLines.map((s) => `- ${s}`).join("\n"));
|
|
186
|
+
section(ROOT_DIGEST_SECTIONS.next, next);
|
|
187
|
+
section(ROOT_DIGEST_SECTIONS.facts, factsText);
|
|
188
|
+
return blocks.join("\n\n");
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Over-cap drop order: [Project facts] → [State] → [Findings]; [Task]/[Next] survive to a
|
|
192
|
+
// final truncate — task continuity beats trivia (paper §3.1 sufficient statistic).
|
|
193
|
+
let summary = render(facts, states, findings);
|
|
194
|
+
if (summary.length > max) summary = render("", states, findings);
|
|
195
|
+
if (summary.length > max) summary = render("", [], findings);
|
|
196
|
+
if (summary.length > max) {
|
|
197
|
+
summary = render("", [], findings.slice(0, Math.max(1, Math.floor(findings.length / 2))));
|
|
198
|
+
}
|
|
199
|
+
summary = truncateMid(summary, max);
|
|
200
|
+
|
|
201
|
+
const boundary = cut >= 0 ? args.branchEntries[cut] : undefined;
|
|
202
|
+
return {
|
|
203
|
+
compaction: {
|
|
204
|
+
summary,
|
|
205
|
+
firstKeptEntryId: (isRecord(boundary) && typeof boundary.id === "string" ? boundary.id : undefined)
|
|
206
|
+
?? prep.firstKeptEntryId,
|
|
207
|
+
tokensBefore: prep.tokensBefore,
|
|
208
|
+
details: { kind: "root-digest", version: 1 },
|
|
209
|
+
},
|
|
210
|
+
// Same CHAR_PER_TOKEN=4 (+8/message) math as the engine budgets — comparable numbers.
|
|
211
|
+
tokensBeforeRecomputed: estimateMessageTokens(messages.map((m) => ({ content: agentMessageText(m) }))),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root Σ (WS-3b/WS-4) — RootStateTracker: a digest-level Σ_t for the NATIVE Pi session.
|
|
3
|
+
*
|
|
4
|
+
* This is NOT the engine's RunStateTracker (core/engine turn loop); it is the root
|
|
5
|
+
* orchestrator's sufficient statistic, living in the extension closure next to
|
|
6
|
+
* controller/sandboxManager. It is runtime-derived — tool outcomes, engine-run mirrors,
|
|
7
|
+
* the user's latest prompt — zero model cooperation required (paper §5.3: observation
|
|
8
|
+
* override; §5.7: small models must not be the state's author by default). The optional
|
|
9
|
+
* fence protocol (enableRootStateFences, default OFF) is the only model-proposed input and
|
|
10
|
+
* rides the SAME V(ΔΣ_t,Σ_t) validator + retry/degrade ladder as engine runs.
|
|
11
|
+
*
|
|
12
|
+
* Caps/dedup/serialization are the engine's own machinery: RUN_STATE_LIMITS, dedupStrings,
|
|
13
|
+
* enforceCaps, applyPatch — reused, never re-implemented here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
applyPatch,
|
|
18
|
+
dedupStrings,
|
|
19
|
+
enforceCaps,
|
|
20
|
+
freshRunState,
|
|
21
|
+
malformedFenceProblem,
|
|
22
|
+
patchErrorText,
|
|
23
|
+
RUN_STATE_LIMITS,
|
|
24
|
+
statePatchObservation,
|
|
25
|
+
type ApproachOutcome,
|
|
26
|
+
type MutableState,
|
|
27
|
+
type PatchError,
|
|
28
|
+
type RunState,
|
|
29
|
+
} from "./run-state.ts";
|
|
30
|
+
import type { Result } from "../util/errors.ts";
|
|
31
|
+
import type { StateFenceResult } from "../text/parsing.ts";
|
|
32
|
+
|
|
33
|
+
/** Consecutive failed outcomes on one key before the rectify hint fires (MAS2 Eq. 5 parity). */
|
|
34
|
+
const RECTIFY_FAILURE_THRESHOLD = 2;
|
|
35
|
+
/** Task restatement cap — mirrors run-state.ts TASK_MAX_CHARS (kept in sync by comment). */
|
|
36
|
+
const ROOT_TASK_MAX_CHARS = 200;
|
|
37
|
+
|
|
38
|
+
/** Root Σ mode — same discriminated union shape as the engine's (active | degraded). */
|
|
39
|
+
type RootStateMode =
|
|
40
|
+
| { readonly kind: "active"; readonly retries: number }
|
|
41
|
+
| { readonly kind: "degraded"; readonly reason: string };
|
|
42
|
+
|
|
43
|
+
export class RootStateTracker {
|
|
44
|
+
private draft: MutableState;
|
|
45
|
+
private mode: RootStateMode = { kind: "active", retries: 0 };
|
|
46
|
+
private opCounter = 0;
|
|
47
|
+
private dirtyFlag = false;
|
|
48
|
+
private readonly failures = new Map<string, number>();
|
|
49
|
+
private pendingObservation: string | undefined;
|
|
50
|
+
private readonly retryMax: number;
|
|
51
|
+
|
|
52
|
+
private constructor(task: string, retryMax: number) {
|
|
53
|
+
const fresh = freshRunState(task);
|
|
54
|
+
this.draft = {
|
|
55
|
+
task: fresh.task,
|
|
56
|
+
nextStep: fresh.nextStep,
|
|
57
|
+
updatedAt: fresh.updatedAt,
|
|
58
|
+
findings: [...fresh.findings],
|
|
59
|
+
verifiedFacts: [...fresh.verifiedFacts],
|
|
60
|
+
openQuestions: [...fresh.openQuestions],
|
|
61
|
+
testedApproaches: { ...fresh.testedApproaches },
|
|
62
|
+
artifacts: { ...fresh.artifacts },
|
|
63
|
+
};
|
|
64
|
+
this.retryMax = Math.max(0, Math.floor(retryMax));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
static fresh(task: string, retryMax = 2): RootStateTracker {
|
|
68
|
+
return new RootStateTracker(task.trim().slice(0, ROOT_TASK_MAX_CHARS), retryMax);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when nothing worth splicing has accumulated — the context transform no-ops. */
|
|
72
|
+
get isEmpty(): boolean {
|
|
73
|
+
return (
|
|
74
|
+
this.draft.findings.length === 0 &&
|
|
75
|
+
this.draft.verifiedFacts.length === 0 &&
|
|
76
|
+
Object.keys(this.draft.testedApproaches).length === 0 &&
|
|
77
|
+
this.draft.nextStep === ""
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get dirty(): boolean {
|
|
82
|
+
return this.dirtyFlag;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
snapshot(): RunState {
|
|
86
|
+
const capped = enforceCaps(this.draft);
|
|
87
|
+
// enforceCaps never fails in practice; the fallback keeps the tracker fail-soft anyway.
|
|
88
|
+
return capped.ok ? capped.value : freshRunState(this.draft.task);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
noteFinding(text: string): void {
|
|
92
|
+
const trimmed = text.trim();
|
|
93
|
+
if (trimmed === "") return;
|
|
94
|
+
this.draft.findings = dedupStrings([...this.draft.findings, trimmed]).slice(-RUN_STATE_LIMITS.findings);
|
|
95
|
+
this.touch();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
noteFact(text: string): void {
|
|
99
|
+
const trimmed = text.trim();
|
|
100
|
+
if (trimmed === "") return;
|
|
101
|
+
this.draft.verifiedFacts = dedupStrings([...this.draft.verifiedFacts, trimmed])
|
|
102
|
+
.slice(-RUN_STATE_LIMITS.verifiedFacts);
|
|
103
|
+
this.touch();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
noteOutcome(key: string, outcome: ApproachOutcome): void {
|
|
107
|
+
const k = key.trim();
|
|
108
|
+
if (k === "") return;
|
|
109
|
+
this.draft.testedApproaches[k] = outcome;
|
|
110
|
+
if (outcome.status === "failed") {
|
|
111
|
+
this.failures.set(k, (this.failures.get(k) ?? 0) + 1);
|
|
112
|
+
} else {
|
|
113
|
+
this.failures.delete(k);
|
|
114
|
+
}
|
|
115
|
+
this.touch();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Tool-outcome feed (WS-4 v1 source): failures become approach outcomes, verbatim. */
|
|
119
|
+
observeToolResult(toolName: string, isError: boolean, reasonFirstLine: string): void {
|
|
120
|
+
if (isError) {
|
|
121
|
+
this.noteOutcome(`tool:${toolName}`, { status: "failed", reason: reasonFirstLine });
|
|
122
|
+
} else {
|
|
123
|
+
// A success clears the tool's failure streak — the state reflects the newest truth.
|
|
124
|
+
if (this.draft.testedApproaches[`tool:${toolName}`] !== undefined) {
|
|
125
|
+
this.noteOutcome(`tool:${toolName}`, { status: "succeeded", evidence: reasonFirstLine });
|
|
126
|
+
} else {
|
|
127
|
+
this.failures.delete(`tool:${toolName}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The user's latest ask IS the next step by definition (WS-4 v1 source). Empty is a no-op. */
|
|
133
|
+
setNextStep(text: string): void {
|
|
134
|
+
const trimmed = text.trim();
|
|
135
|
+
if (trimmed === "") return;
|
|
136
|
+
this.draft.nextStep = trimmed.slice(0, 300);
|
|
137
|
+
this.touch();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* WS-4 mirror: absorb a finished engine run's Σ (same object the SkillState harvest
|
|
142
|
+
* consumes — one source, two sinks). Findings/facts dedup through the shared key; the
|
|
143
|
+
* engine's approach outcomes ride along under their own keys.
|
|
144
|
+
*/
|
|
145
|
+
absorbEngineState(engine: RunState): void {
|
|
146
|
+
this.draft.findings = dedupStrings([...this.draft.findings, ...engine.findings])
|
|
147
|
+
.slice(-RUN_STATE_LIMITS.findings);
|
|
148
|
+
this.draft.verifiedFacts = dedupStrings([...this.draft.verifiedFacts, ...engine.verifiedFacts])
|
|
149
|
+
.slice(-RUN_STATE_LIMITS.verifiedFacts);
|
|
150
|
+
for (const [key, outcome] of Object.entries(engine.testedApproaches)) {
|
|
151
|
+
this.draft.testedApproaches[key] = outcome;
|
|
152
|
+
}
|
|
153
|
+
if (this.draft.task === "") this.draft.task = engine.task;
|
|
154
|
+
this.touch();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* WS-4.2 (default OFF): model-proposed ΔΣ_t fences through the ONE validator. EXACT ladder
|
|
159
|
+
* parity with the engine's `applyStatePatches` (N3): every fence is processed — accepted
|
|
160
|
+
* deltas land sequentially, ALL problems accumulate into ONE observation (error-as-
|
|
161
|
+
* observation), and only past `runStateRetryMax` total rejections the tracker degrades and
|
|
162
|
+
* fences stop being applied. Wording delegates to run-state.ts (N1) — one source.
|
|
163
|
+
*/
|
|
164
|
+
applyFences(fences: readonly StateFenceResult[]): void {
|
|
165
|
+
if (this.mode.kind !== "active" || fences.length === 0) return;
|
|
166
|
+
let state = this.snapshot();
|
|
167
|
+
const problems: string[] = [];
|
|
168
|
+
for (const fence of fences) {
|
|
169
|
+
if (!fence.ok) {
|
|
170
|
+
problems.push(malformedFenceProblem(fence.error));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const next: Result<RunState, PatchError> = applyPatch(state, fence.value, ++this.opCounter);
|
|
174
|
+
if (next.ok) {
|
|
175
|
+
state = next.value;
|
|
176
|
+
} else {
|
|
177
|
+
problems.push(patchErrorText(next.error));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (problems.length === 0) {
|
|
181
|
+
this.mode = { kind: "active", retries: 0 };
|
|
182
|
+
this.pendingObservation = undefined;
|
|
183
|
+
this.draft = this.toMutable(state);
|
|
184
|
+
this.touch();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const retries = this.mode.retries + problems.length;
|
|
188
|
+
this.pendingObservation = statePatchObservation(problems);
|
|
189
|
+
// Accepted deltas in a partially-failing batch still land — engine parity: real work is
|
|
190
|
+
// never rolled back just because a sibling fence was malformed.
|
|
191
|
+
this.draft = this.toMutable(state);
|
|
192
|
+
this.touch();
|
|
193
|
+
if (retries > this.retryMax) {
|
|
194
|
+
this.mode = { kind: "degraded", reason: `state-patch retry cap exceeded (${retries} rejected)` };
|
|
195
|
+
} else {
|
|
196
|
+
this.mode = { kind: "active", retries };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Consume (and clear) the pending fence-rejection observation, if any. */
|
|
201
|
+
takePendingObservation(): string | undefined {
|
|
202
|
+
const observation = this.pendingObservation;
|
|
203
|
+
this.pendingObservation = undefined;
|
|
204
|
+
return observation;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* WS-4 rectify parity (budget.ts:rectify ideas, deterministic): after N consecutive
|
|
209
|
+
* failed outcomes on one key, suggest the local fix — never a model switch.
|
|
210
|
+
*/
|
|
211
|
+
rectifyHint(): string | undefined {
|
|
212
|
+
if (this.mode.kind !== "active") return undefined;
|
|
213
|
+
for (const [key, count] of this.failures) {
|
|
214
|
+
if (count >= RECTIFY_FAILURE_THRESHOLD) {
|
|
215
|
+
return `[rectify] '${key}' failed ${count}× consecutively — narrow the approach ` +
|
|
216
|
+
"(different path/tool/slice) instead of retrying blind (MAS2 Eq. 5).";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private toMutable(state: RunState): MutableState {
|
|
223
|
+
return {
|
|
224
|
+
task: state.task,
|
|
225
|
+
nextStep: state.nextStep,
|
|
226
|
+
updatedAt: state.updatedAt,
|
|
227
|
+
findings: [...state.findings],
|
|
228
|
+
verifiedFacts: [...state.verifiedFacts],
|
|
229
|
+
openQuestions: [...state.openQuestions],
|
|
230
|
+
testedApproaches: { ...state.testedApproaches },
|
|
231
|
+
artifacts: { ...state.artifacts },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private touch(): void {
|
|
236
|
+
this.opCounter += 1;
|
|
237
|
+
this.draft.updatedAt = this.opCounter;
|
|
238
|
+
this.dirtyFlag = true;
|
|
239
|
+
}
|
|
240
|
+
}
|