@hydraharness/harness-compaction 0.1.1-rc.10
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/LICENSE +21 -0
- package/README.md +91 -0
- package/lib/index.js +183 -0
- package/lib/invariant.js +217 -0
- package/lib/types/brand.d.ts +10 -0
- package/lib/types/brand.js +9 -0
- package/lib/types/checkpoint.d.ts +40 -0
- package/lib/types/checkpoint.js +36 -0
- package/lib/types/index.d.ts +133 -0
- package/lib/types/index.js +50 -0
- package/lib/types/invariant.d.ts +13 -0
- package/lib/types/invariant.js +258 -0
- package/lib/types/tool-pairing.d.ts +26 -0
- package/lib/types/tool-pairing.js +101 -0
- package/lib/types/types.d.ts +131 -0
- package/lib/types/types.js +10 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# @hydraharness/harness-compaction
|
|
2
|
+
|
|
3
|
+
The **`CompactionEngine`** (`ctx.compaction`) defines WHAT compaction does — decide when history is too large and summarize an older range into a single surface node — without saying HOW.
|
|
4
|
+
|
|
5
|
+
This package owns the Service Definition role of the compaction capability, split so each role evolves (and swaps) independently:
|
|
6
|
+
|
|
7
|
+
| Package | Role |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `@hydraharness/harness-compaction` (this) | Service Definition: abstract service + `compaction/*` events + `CompactionResult` + correlated checkpoint-source constructor + tool-pairing boundary helpers |
|
|
10
|
+
| `@hydraharness/harness-compaction-basic` | Service Provider: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
|
|
11
|
+
| `@hydraharness/harness-command-compact` | Consumer: the human `/compact` command over `ctx.compaction.compactNow()` |
|
|
12
|
+
|
|
13
|
+
Unlike the bash seam, this Service Definition depends on `@hydraharness/harness-session` and `@hydraharness/harness-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "Service Definition depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
|
14
|
+
|
|
15
|
+
## Service API (`ctx.compaction`)
|
|
16
|
+
|
|
17
|
+
All three operations are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this Service Definition.
|
|
18
|
+
|
|
19
|
+
| Member | Semantics |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
|
|
22
|
+
| `compactNow(agent, signal)` | Explicitly compact one useful balanced older span even below automatic pressure. It synchronously reserves idle turn admission before yielding, writes nothing when no useful span exists, records a standalone `compaction/* { turn: null }` attempt before summarization, and awaits its durability checkpoint before release. Expected operational failures use `ManualCompactionError`; cancellation rethrows the exact abort reason. |
|
|
23
|
+
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source comes from `compactCheckpointSource(compactionId)`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
|
|
24
|
+
|
|
25
|
+
`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/subsystems/compaction.md#compactionresult).
|
|
26
|
+
|
|
27
|
+
`compactIfNeeded` and `compactNow` take a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization. Automatic and explicit-region brackets recover their numeric owner from the currently open turn. Manual brackets require no open turn and stamp `turn: null`.
|
|
28
|
+
|
|
29
|
+
`ManualCompactionError.code` is the closed set `busy | changed | summary | commit | persistence`. `changed` and `summary` mean the selected conversation surface was not replaced, but their failed attempt is still recorded in the session log. `commit` is deliberately neutral about partial mutation, and `persistence` means the in-memory bracket closed but its explicit flush failed.
|
|
30
|
+
|
|
31
|
+
## Tool-pairing boundaries
|
|
32
|
+
|
|
33
|
+
The Service Definition exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper validates that the event sequence is in the current surface and answers from balances cached per cut in surface order.
|
|
34
|
+
|
|
35
|
+
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-entry count. An unchanged generation extends the fold with unseen tail entries only; a log-only append with no new surface entry does no event reads, while a replacement generation rebuilds current membership and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
|
|
36
|
+
|
|
37
|
+
## Surface contract
|
|
38
|
+
|
|
39
|
+
`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, and `tool/result` may carry `surfaceOp`. A `compaction/*` event therefore **cannot** appear on the surface. A successful compaction instead:
|
|
40
|
+
|
|
41
|
+
1. appends `compaction/start` (log-only) — acquires the lock,
|
|
42
|
+
2. summarizes the range,
|
|
43
|
+
3. appends `compaction/summary` (log-only) with the summary, range, shadowed seqs, token count, and provider/model call envelope,
|
|
44
|
+
4. appends a single `user/message` with `source: compactCheckpointSource(compactionId, sourceCommandId?)` and `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**,
|
|
45
|
+
5. appends `compaction/end` (log-only) — releases the lock.
|
|
46
|
+
|
|
47
|
+
The surface mutation (step 4) sits **inside** the lock bracket: `compaction/end` is the last event, so the lock is never released before the mutation lands. A crash between `compaction/start` and `compaction/end` therefore leaves a detectable orphaned lock (a `compaction/start` with no matching `compaction/end`) rather than a `compaction/end` that falsely claims compaction finished while the surface was never shadowed.
|
|
48
|
+
|
|
49
|
+
The marker pair names lock acquisition and release, not an exclusive event container. An idle `inject()` may append unrelated context between a manual start and end while summarization is pending. Manual stability therefore revalidates the selected span rather than demanding whole-surface equality; the positional replacement leaves that injected context visible after the checkpoint. Automatic compaction keeps whole-surface equality inside its active turn.
|
|
50
|
+
|
|
51
|
+
`deriveMessages()` then renders the summary as a user-role message followed by the retained nodes. The shadowed events remain in the raw log, so replay is deterministic.
|
|
52
|
+
|
|
53
|
+
## Blocking
|
|
54
|
+
|
|
55
|
+
Compaction is serialized by one log-recorded lock shared by all entry points. Tail inspection independently finds the latest unmatched `compaction/start` and the newest `session/end-seed`. An unmatched start after that boundary is live and reports `busy`; an older unmatched start is stale evidence from a prior process lifecycle and does not block. The same end-seed transition clears the invariant companion's replay trace. A live bracket cannot cross a `turn/start` or `turn/end`; during adoption, repair boundaries in the inherited prefix remain replayable when the later end-seed proves their open bracket stale.
|
|
56
|
+
|
|
57
|
+
The lock is the durable bracket, not a `WeakSet`, wrapper mutex, or client-side anchor. `compaction/start` is appended synchronously before summarization yields. Every later failure makes exactly one `compaction/end { error }` attempt; if that close append itself fails, the unmatched start remains the intentional busy signal and no flush is attempted. A successfully closed manual attempt is flushed even when it reports `changed` or `summary`, preserving the recorded attempt before turn admission is released.
|
|
58
|
+
|
|
59
|
+
## Events
|
|
60
|
+
|
|
61
|
+
The `compaction/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`, and all three are log-only (no `surfaceOp`). Per-event payloads and semantics are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md).
|
|
62
|
+
|
|
63
|
+
## Implementing a backend
|
|
64
|
+
|
|
65
|
+
Subclass `CompactionEngine`, implement `compactIfNeeded`, `compactNow`, and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compaction`. Every successful backend creates its replacement user message source with `compactCheckpointSource(compactionId, sourceCommandId?)`; the required `compactionId` correlates the checkpoint with its `compaction/*` transaction, while `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
|
|
66
|
+
|
|
67
|
+
## Recognizing a checkpoint outside the host program (`./checkpoint`)
|
|
68
|
+
|
|
69
|
+
`compactCheckpointSource()`, `CompactionCheckpointSource`, and `isCompactCheckpointSource()` are declared on the `@hydraharness/harness-compaction/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The constructor requires the owning `CompactionId`, preventing backends from writing an uncorrelated marker that the package invariant must reject. The leaf imports no cordis and declares no module augmentation (the [`hydra-commands/brand`](../../interaction/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `@hydraharness/harness-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to the leaf's source type, so renaming the plugin id there is a compile error here.
|
|
70
|
+
|
|
71
|
+
## Model Experience
|
|
72
|
+
|
|
73
|
+
### Conversation history, when a backend is invoked
|
|
74
|
+
|
|
75
|
+
#### What the model sees
|
|
76
|
+
|
|
77
|
+
A successful implementation replaces an older surface range with one user-role summary checkpoint — a `user/message` carrying `surfaceOp: { op: 'replace', start, end }`; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.
|
|
78
|
+
|
|
79
|
+
#### Token effect
|
|
80
|
+
|
|
81
|
+
Zero direct tokens from this Service Definition. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged.
|
|
82
|
+
|
|
83
|
+
#### KV Cache effect
|
|
84
|
+
|
|
85
|
+
A successful backend replacement invalidates reuse from the first shadowed history token; the seam itself does not alter a request.
|
|
86
|
+
|
|
87
|
+
## Known Limitations and Deferred Work
|
|
88
|
+
|
|
89
|
+
- **Human command, not a model tool** — `@hydraharness/harness-command-compact` exposes argument-free `/compact` through `ctx.commands`; no model-facing compaction tool is registered.
|
|
90
|
+
- **Some single-unit overflow is out of contract** — balanced summary compaction cannot split one indivisible unit. The optional pruning companion can still repair a closed tool pair when text-bearing tool-result bulk is removable; a large non-tool node or a tool unit whose non-prunable remainder is oversized cannot be compacted.
|
|
91
|
+
- **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Service } from "@hydraharness/cordis";
|
|
2
|
+
//#region lib/types/brand.js
|
|
3
|
+
/**
|
|
4
|
+
* Brand an implementation-minted compaction identity.
|
|
5
|
+
* @param id - opaque transaction identity.
|
|
6
|
+
* @returns the same string, branded; no validation is performed.
|
|
7
|
+
*/
|
|
8
|
+
function CompactionId(id) {
|
|
9
|
+
return id;
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region lib/types/tool-pairing.js
|
|
13
|
+
/**
|
|
14
|
+
* Tool-pairing balance over a session surface. Compaction changes surface
|
|
15
|
+
* positions, so safe cuts are derived from tool-call/result content in current
|
|
16
|
+
* surface order rather than step markers.
|
|
17
|
+
* @module @hydraharness/harness-compaction/tool-pairing
|
|
18
|
+
*/
|
|
19
|
+
const balanceCacheBySession = /* @__PURE__ */ new WeakMap();
|
|
20
|
+
/** Return how one surface event changes the in-progress tool-call count. */
|
|
21
|
+
function eventDelta(event) {
|
|
22
|
+
switch (event.type) {
|
|
23
|
+
case "assistant/message": return event.data.message.content.filter((block) => block.type === "tool-call").length;
|
|
24
|
+
case "tool/result": return -1;
|
|
25
|
+
default: return 0;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Read and validate the event named by a surface sequence. */
|
|
29
|
+
function eventForSeq(events, seq) {
|
|
30
|
+
const event = events[seq];
|
|
31
|
+
if (event === void 0 || event.seq !== seq) throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`);
|
|
32
|
+
return event;
|
|
33
|
+
}
|
|
34
|
+
/** Fold surface sequences not yet in the cache into its balance state. */
|
|
35
|
+
function extendCache(session, cache, seqs) {
|
|
36
|
+
const processed = cache.cutBalanced.length - 1;
|
|
37
|
+
const tail = seqs.slice(processed);
|
|
38
|
+
const events = session.events;
|
|
39
|
+
const pendingCuts = [];
|
|
40
|
+
let inProgressToolCalls = cache.inProgressToolCalls;
|
|
41
|
+
for (const seq of tail) {
|
|
42
|
+
inProgressToolCalls += eventDelta(eventForSeq(events, seq));
|
|
43
|
+
if (inProgressToolCalls < 0) throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`);
|
|
44
|
+
pendingCuts.push(inProgressToolCalls === 0);
|
|
45
|
+
}
|
|
46
|
+
tail.forEach((seq, offset) => cache.indexBySeq.set(seq, processed + offset));
|
|
47
|
+
cache.cutBalanced = cache.cutBalanced.concat(pendingCuts);
|
|
48
|
+
cache.inProgressToolCalls = inProgressToolCalls;
|
|
49
|
+
return cache;
|
|
50
|
+
}
|
|
51
|
+
/** Return balance state synchronized with the current session surface. */
|
|
52
|
+
function balanceCache(session) {
|
|
53
|
+
const surface = session.surface;
|
|
54
|
+
const seqs = surface.nodes;
|
|
55
|
+
const generation = surface.replaceGeneration;
|
|
56
|
+
const cached = balanceCacheBySession.get(session);
|
|
57
|
+
if (cached === void 0 || cached.generation !== generation || cached.cutBalanced.length - 1 > seqs.length) {
|
|
58
|
+
const rebuilt = extendCache(session, {
|
|
59
|
+
generation,
|
|
60
|
+
cutBalanced: [true],
|
|
61
|
+
indexBySeq: /* @__PURE__ */ new Map(),
|
|
62
|
+
inProgressToolCalls: 0
|
|
63
|
+
}, seqs);
|
|
64
|
+
balanceCacheBySession.set(session, rebuilt);
|
|
65
|
+
return rebuilt;
|
|
66
|
+
}
|
|
67
|
+
if (cached.cutBalanced.length - 1 < seqs.length) return extendCache(session, cached, seqs);
|
|
68
|
+
return cached;
|
|
69
|
+
}
|
|
70
|
+
/** Balance of the cut at a sequence's position plus offset, rejecting seqs outside current membership. */
|
|
71
|
+
function cutBalance(cache, seq, offset) {
|
|
72
|
+
const index = cache.indexBySeq.get(seq);
|
|
73
|
+
const balanced = index === void 0 ? void 0 : cache.cutBalanced[index + offset];
|
|
74
|
+
if (balanced === void 0) throw new Error(`tool-pairing balance: surface seq ${seq} not found`);
|
|
75
|
+
return balanced;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether the cut immediately before a current surface sequence is tool-pairing balanced.
|
|
79
|
+
* @param session - session whose surface is checked.
|
|
80
|
+
* @param seq - event sequence whose leading cut is checked.
|
|
81
|
+
* @returns true when no unanswered tool call crosses the cut.
|
|
82
|
+
* @throws when the seq is absent from the current surface, a surface sequence has no
|
|
83
|
+
* matching log event, or a tool result has no preceding open call.
|
|
84
|
+
*/
|
|
85
|
+
function toolPairingBalancedBefore(session, seq) {
|
|
86
|
+
return cutBalance(balanceCache(session), seq, 0);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whether the cut immediately after a current surface sequence is tool-pairing balanced.
|
|
90
|
+
* @param session - session whose surface is checked.
|
|
91
|
+
* @param seq - event sequence whose trailing cut is checked.
|
|
92
|
+
* @returns true when no unanswered tool call crosses the cut.
|
|
93
|
+
* @throws when the seq is absent from the current surface, a surface sequence has no
|
|
94
|
+
* matching log event, or a tool result has no preceding open call.
|
|
95
|
+
*/
|
|
96
|
+
function toolPairingBalancedAfter(session, seq) {
|
|
97
|
+
return cutBalance(balanceCache(session), seq, 1);
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region lib/types/checkpoint.js
|
|
101
|
+
/**
|
|
102
|
+
* Compaction checkpoint provenance: the correlated source constructor and type
|
|
103
|
+
* every backend uses for its replacement user message, plus the predicate that
|
|
104
|
+
* recognizes persisted checkpoints.
|
|
105
|
+
*
|
|
106
|
+
* The seam itself lives in `@hydraharness/harness-compaction`, which re-exports these
|
|
107
|
+
* contracts; this module is a pure type/value/predicate outlet (no cordis
|
|
108
|
+
* imports, no module augmentation) so client and wire programs can name the
|
|
109
|
+
* checkpoint source without loading the host plugin's Context merges — the
|
|
110
|
+
* `hydra-commands/brand` shape.
|
|
111
|
+
*
|
|
112
|
+
* @module @hydraharness/harness-compaction/checkpoint
|
|
113
|
+
*/
|
|
114
|
+
const COMPACT_CHECKPOINT_MARKER = Object.freeze({
|
|
115
|
+
kind: "plugin",
|
|
116
|
+
plugin: "compact"
|
|
117
|
+
});
|
|
118
|
+
/**
|
|
119
|
+
* Create checkpoint provenance correlated with one compaction transaction.
|
|
120
|
+
* @param compactionId - owning compaction identity.
|
|
121
|
+
* @param sourceCommandId - initiating manual command, when present.
|
|
122
|
+
* @returns immutable checkpoint source.
|
|
123
|
+
*/
|
|
124
|
+
function compactCheckpointSource(compactionId, sourceCommandId) {
|
|
125
|
+
return Object.freeze({
|
|
126
|
+
...COMPACT_CHECKPOINT_MARKER,
|
|
127
|
+
compactionId,
|
|
128
|
+
...sourceCommandId === void 0 ? {} : { sourceCommandId }
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Test whether a persisted message source identifies a compaction checkpoint.
|
|
133
|
+
* @param source - source restored from a surface user message.
|
|
134
|
+
* @returns whether the source carries the backend-independent checkpoint marker.
|
|
135
|
+
*/
|
|
136
|
+
function isCompactCheckpointSource(source) {
|
|
137
|
+
return source.kind === "plugin" && source.plugin === COMPACT_CHECKPOINT_MARKER.plugin;
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region lib/types/index.js
|
|
141
|
+
/**
|
|
142
|
+
* Compaction Service Definition (`ctx.compaction`): providers decide when to
|
|
143
|
+
* compact and replace a history range with one summary node by subclassing
|
|
144
|
+
* {@link CompactionEngine}. This interface necessarily depends on session and LLM
|
|
145
|
+
* vocabulary; the rationale is in the
|
|
146
|
+
* [compaction Agent Note](../../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
|
147
|
+
* @module @hydraharness/harness-compaction
|
|
148
|
+
*/
|
|
149
|
+
/**
|
|
150
|
+
* Expected manual-compaction failure suitable for a direct human-command result.
|
|
151
|
+
* Shared durable-lock entry assertions may also throw the `busy` subtype from
|
|
152
|
+
* automatic compaction paths.
|
|
153
|
+
*/
|
|
154
|
+
var ManualCompactionError = class extends Error {
|
|
155
|
+
code;
|
|
156
|
+
name = "ManualCompactionError";
|
|
157
|
+
/**
|
|
158
|
+
* Create one classified compaction failure.
|
|
159
|
+
* @param code - stable failure class; `busy` may originate from any compaction entry path.
|
|
160
|
+
* @param message - backend diagnostic retained as the Error message.
|
|
161
|
+
* @param options - optional original failure.
|
|
162
|
+
*/
|
|
163
|
+
constructor(code, message, options) {
|
|
164
|
+
super(message, options);
|
|
165
|
+
this.code = code;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Abstract compaction service. Implementations own trigger policy, retention,
|
|
170
|
+
* and summarization, and may consume a separate measurement service. A
|
|
171
|
+
* successful run replaces the selected surface span with one summary node and
|
|
172
|
+
* prevents concurrent compaction of the same session. The replacement user
|
|
173
|
+
* message uses {@link compactCheckpointSource} with the transaction identity
|
|
174
|
+
* so consumers recognize and correlate it independently of the backend. Load
|
|
175
|
+
* one implementation per context as `ctx.compaction`.
|
|
176
|
+
*/
|
|
177
|
+
var CompactionEngine = class extends Service {
|
|
178
|
+
constructor(ctx) {
|
|
179
|
+
super(ctx, "compaction");
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
//#endregion
|
|
183
|
+
export { CompactionEngine, CompactionEngine as default, CompactionId, ManualCompactionError, compactCheckpointSource, isCompactCheckpointSource, toolPairingBalancedAfter, toolPairingBalancedBefore };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { isReplacementSurfaceEvent } from "@hydraharness/harness-session";
|
|
2
|
+
//#region lib/types/checkpoint.js
|
|
3
|
+
/**
|
|
4
|
+
* Compaction checkpoint provenance: the correlated source constructor and type
|
|
5
|
+
* every backend uses for its replacement user message, plus the predicate that
|
|
6
|
+
* recognizes persisted checkpoints.
|
|
7
|
+
*
|
|
8
|
+
* The seam itself lives in `@hydraharness/harness-compaction`, which re-exports these
|
|
9
|
+
* contracts; this module is a pure type/value/predicate outlet (no cordis
|
|
10
|
+
* imports, no module augmentation) so client and wire programs can name the
|
|
11
|
+
* checkpoint source without loading the host plugin's Context merges — the
|
|
12
|
+
* `hydra-commands/brand` shape.
|
|
13
|
+
*
|
|
14
|
+
* @module @hydraharness/harness-compaction/checkpoint
|
|
15
|
+
*/
|
|
16
|
+
const COMPACT_CHECKPOINT_MARKER = Object.freeze({
|
|
17
|
+
kind: "plugin",
|
|
18
|
+
plugin: "compact"
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* Test whether a persisted message source identifies a compaction checkpoint.
|
|
22
|
+
* @param source - source restored from a surface user message.
|
|
23
|
+
* @returns whether the source carries the backend-independent checkpoint marker.
|
|
24
|
+
*/
|
|
25
|
+
function isCompactCheckpointSource(source) {
|
|
26
|
+
return source.kind === "plugin" && source.plugin === COMPACT_CHECKPOINT_MARKER.plugin;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region lib/types/invariant.js
|
|
30
|
+
/** Package-owned compaction log-stream invariants. @module @hydraharness/harness-compaction/invariant */
|
|
31
|
+
const PACKAGE_NAME = "@hydraharness/harness-compaction";
|
|
32
|
+
/** Cordis companion plugin name. */
|
|
33
|
+
const name = "compaction-invariant";
|
|
34
|
+
/** Service required before the companion can reserve package ownership. */
|
|
35
|
+
const inject = ["invariants"];
|
|
36
|
+
/** Require a durable opaque identity to be a non-empty string. */
|
|
37
|
+
function validateId(value, label, fail) {
|
|
38
|
+
if (typeof value !== "string" || value.length === 0) fail(`${label} must be a non-empty string`);
|
|
39
|
+
}
|
|
40
|
+
/** Keep the optional initiating command identity stable across one transaction. */
|
|
41
|
+
function validateSourceCommandId(eventType, value, expected, fail) {
|
|
42
|
+
if (value !== void 0) validateId(value, `${eventType} sourceCommandId`, fail);
|
|
43
|
+
if (value !== expected) fail(`${eventType} sourceCommandId ${String(value)} does not match compaction/start sourceCommandId ${String(expected)}`);
|
|
44
|
+
}
|
|
45
|
+
/** Validate one replacement checkpoint against its open compaction transaction. */
|
|
46
|
+
function validateCheckpoint(trace, event, fail) {
|
|
47
|
+
const source = event.data.source;
|
|
48
|
+
validateId(source.compactionId, "compaction checkpoint compactionId", fail);
|
|
49
|
+
if (source.sourceCommandId !== void 0) validateId(source.sourceCommandId, "compaction checkpoint sourceCommandId", fail);
|
|
50
|
+
const open = trace.compaction;
|
|
51
|
+
if (open === void 0) fail("compaction checkpoint has no matching compaction/start");
|
|
52
|
+
if (source.compactionId !== open.compactionId) fail(`compaction checkpoint id ${source.compactionId} does not match compaction/start id ${open.compactionId}`);
|
|
53
|
+
validateSourceCommandId("compaction checkpoint", source.sourceCommandId, open.sourceCommandId, fail);
|
|
54
|
+
}
|
|
55
|
+
/** Compaction starts still unmatched when a later seed boundary made them stale. */
|
|
56
|
+
function inheritedOrphanStartSeqs(events) {
|
|
57
|
+
const stale = /* @__PURE__ */ new Set();
|
|
58
|
+
let openStartSeq;
|
|
59
|
+
for (const event of events) if (event.type === "compaction/start") openStartSeq = event.seq;
|
|
60
|
+
else if (event.type === "compaction/end") openStartSeq = void 0;
|
|
61
|
+
else if (event.type === "session/end-seed") {
|
|
62
|
+
if (openStartSeq !== void 0) stale.add(openStartSeq);
|
|
63
|
+
openStartSeq = void 0;
|
|
64
|
+
}
|
|
65
|
+
return stale;
|
|
66
|
+
}
|
|
67
|
+
/** Keep every live compaction bracket on one side of each turn boundary. */
|
|
68
|
+
function validateTurnBoundary(trace, event, fail) {
|
|
69
|
+
if (event.type !== "turn/start" && event.type !== "turn/end" || trace.compaction === void 0) return;
|
|
70
|
+
const owner = trace.compaction.turn === null ? "standalone compaction" : `compaction for turn ${trace.compaction.turn}`;
|
|
71
|
+
fail(`${event.type} cannot cross an open ${owner}`);
|
|
72
|
+
}
|
|
73
|
+
/** Advance the committed turn cursor after its boundary has been accepted. */
|
|
74
|
+
function applyTurnBoundary(trace, event) {
|
|
75
|
+
if (event.type === "turn/start") {
|
|
76
|
+
trace.openTurn = event.data.turn;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (event.type === "turn/end") {
|
|
80
|
+
trace.openTurn = null;
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
/** Require a numbered bracket inside its exact turn, or a standalone bracket between turns. */
|
|
86
|
+
function validateOwner(owner, openTurn, eventType, fail) {
|
|
87
|
+
if (owner === null) {
|
|
88
|
+
if (openTurn !== null) fail(`${eventType} is standalone but turn ${openTurn} is open`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (openTurn === null) fail(`${eventType} for turn ${owner} appended outside any open turn`);
|
|
92
|
+
if (owner !== openTurn) fail(`${eventType} names turn ${owner} but open turn is ${openTurn}`);
|
|
93
|
+
}
|
|
94
|
+
/** Validate one compaction event without advancing committed trace state. */
|
|
95
|
+
function validateCompactionEvent(trace, event, fail) {
|
|
96
|
+
if (event.type === "session/end-seed") return { kind: "end-seed" };
|
|
97
|
+
if (event.type === "user/message" && isReplacementSurfaceEvent(event) && isCompactCheckpointSource(event.data.source)) {
|
|
98
|
+
validateCheckpoint(trace, event, fail);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (event.type !== "compaction/start" && event.type !== "compaction/summary" && event.type !== "compaction/end") return;
|
|
102
|
+
const open = trace.compaction;
|
|
103
|
+
if (event.type === "compaction/start") {
|
|
104
|
+
validateId(event.data.compactionId, "compaction/start compactionId", fail);
|
|
105
|
+
if (event.data.sourceCommandId !== void 0) validateId(event.data.sourceCommandId, "compaction/start sourceCommandId", fail);
|
|
106
|
+
if (open !== void 0) fail(`compaction/start while ${open.turn === null ? "standalone compaction" : `turn ${open.turn}`} is still compacting`);
|
|
107
|
+
validateOwner(event.data.turn, trace.openTurn, event.type, fail);
|
|
108
|
+
return {
|
|
109
|
+
kind: "start",
|
|
110
|
+
compactionId: event.data.compactionId,
|
|
111
|
+
sourceCommandId: event.data.sourceCommandId,
|
|
112
|
+
startSeq: event.seq,
|
|
113
|
+
turn: event.data.turn
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (event.type === "compaction/summary") {
|
|
117
|
+
validateId(event.data.compactionId, "compaction/summary compactionId", fail);
|
|
118
|
+
if (event.data.sourceCommandId !== void 0) validateId(event.data.sourceCommandId, "compaction/summary sourceCommandId", fail);
|
|
119
|
+
if (open === void 0) fail("compaction/summary has no matching compaction/start");
|
|
120
|
+
if (event.data.compactionId !== open.compactionId) fail(`compaction/summary id ${event.data.compactionId} does not match compaction/start id ${open.compactionId}`);
|
|
121
|
+
validateSourceCommandId("compaction/summary", event.data.sourceCommandId, open.sourceCommandId, fail);
|
|
122
|
+
validateOwner(open.turn, trace.openTurn, event.type, fail);
|
|
123
|
+
if (open.summarized) fail("compaction/summary repeated within one compaction");
|
|
124
|
+
const seqs = event.data.shadowedSeqs;
|
|
125
|
+
if (seqs.length === 0) fail("compaction/summary shadowedSeqs must be non-empty");
|
|
126
|
+
if (seqs[0] !== event.data.shadowedRange.start || seqs.at(-1) !== event.data.shadowedRange.end) fail("compaction/summary shadowedRange must match the first and last shadowedSeqs");
|
|
127
|
+
if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) fail("compaction/summary shadowedTokenCount must be a non-negative safe integer");
|
|
128
|
+
return {
|
|
129
|
+
kind: "summary",
|
|
130
|
+
compactionId: open.compactionId,
|
|
131
|
+
sourceCommandId: open.sourceCommandId,
|
|
132
|
+
startSeq: open.startSeq,
|
|
133
|
+
turn: open.turn
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
validateId(event.data.compactionId, "compaction/end compactionId", fail);
|
|
137
|
+
if (event.data.sourceCommandId !== void 0) validateId(event.data.sourceCommandId, "compaction/end sourceCommandId", fail);
|
|
138
|
+
if (open === void 0) fail("compaction/end has no matching compaction/start");
|
|
139
|
+
if (event.data.compactionId !== open.compactionId) fail(`compaction/end id ${event.data.compactionId} does not match compaction/start id ${open.compactionId}`);
|
|
140
|
+
validateSourceCommandId("compaction/end", event.data.sourceCommandId, open.sourceCommandId, fail);
|
|
141
|
+
if (event.data.turn !== open.turn) fail(`compaction/end owner ${String(event.data.turn)} does not match compaction/start owner ${String(open.turn)}`);
|
|
142
|
+
validateOwner(open.turn, trace.openTurn, event.type, fail);
|
|
143
|
+
if (event.data.error === void 0 && !open.summarized) fail("successful compaction/end requires one compaction/summary");
|
|
144
|
+
return { kind: "end" };
|
|
145
|
+
}
|
|
146
|
+
/** Apply one committed compaction transition. */
|
|
147
|
+
function applyCompactionTransition(transition) {
|
|
148
|
+
if (transition.kind === "start") return {
|
|
149
|
+
compactionId: transition.compactionId,
|
|
150
|
+
sourceCommandId: transition.sourceCommandId,
|
|
151
|
+
startSeq: transition.startSeq,
|
|
152
|
+
turn: transition.turn,
|
|
153
|
+
summarized: false
|
|
154
|
+
};
|
|
155
|
+
if (transition.kind === "summary") return {
|
|
156
|
+
compactionId: transition.compactionId,
|
|
157
|
+
sourceCommandId: transition.sourceCommandId,
|
|
158
|
+
startSeq: transition.startSeq,
|
|
159
|
+
turn: transition.turn,
|
|
160
|
+
summarized: true
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Install compaction start/summary/end checks. */
|
|
164
|
+
const install = Object.assign((ctx, fail) => {
|
|
165
|
+
const traces = /* @__PURE__ */ new WeakMap();
|
|
166
|
+
const staged = /* @__PURE__ */ new WeakMap();
|
|
167
|
+
const seed = (session) => {
|
|
168
|
+
const trace = {
|
|
169
|
+
openTurn: null,
|
|
170
|
+
compaction: void 0
|
|
171
|
+
};
|
|
172
|
+
traces.set(session, trace);
|
|
173
|
+
const staleOrphanStartSeqs = inheritedOrphanStartSeqs(session.events);
|
|
174
|
+
for (const event of session.events) {
|
|
175
|
+
if (trace.compaction === void 0 || !staleOrphanStartSeqs.has(trace.compaction.startSeq)) validateTurnBoundary(trace, event, fail);
|
|
176
|
+
const transition = validateCompactionEvent(trace, event, fail);
|
|
177
|
+
if (transition !== void 0) trace.compaction = applyCompactionTransition(transition);
|
|
178
|
+
applyTurnBoundary(trace, event);
|
|
179
|
+
}
|
|
180
|
+
return trace;
|
|
181
|
+
};
|
|
182
|
+
const traceFor = (session) => traces.get(session) ?? seed(session);
|
|
183
|
+
for (const session of ctx.sessions.list()) seed(session);
|
|
184
|
+
ctx.on("session/created", (session) => {
|
|
185
|
+
seed(session);
|
|
186
|
+
}, { global: true });
|
|
187
|
+
ctx.on("session/event", (session, event) => {
|
|
188
|
+
const trace = traceFor(session);
|
|
189
|
+
validateTurnBoundary(trace, event, fail);
|
|
190
|
+
if (applyTurnBoundary(trace, event)) return;
|
|
191
|
+
if (event.type !== "session/end-seed" && event.type !== "compaction/start" && event.type !== "compaction/summary" && event.type !== "compaction/end") return;
|
|
192
|
+
const candidate = staged.get(event);
|
|
193
|
+
/* v8 ignore next -- internal/dispatch stages every compaction event */
|
|
194
|
+
if (candidate === void 0 || candidate.session !== session) return fail("compaction event published without pre-commit validation");
|
|
195
|
+
staged.delete(event);
|
|
196
|
+
trace.compaction = applyCompactionTransition(candidate.transition);
|
|
197
|
+
}, { global: true });
|
|
198
|
+
ctx.on("internal/dispatch", (_mode, eventName, args) => {
|
|
199
|
+
if (eventName !== "session/event") return;
|
|
200
|
+
const [session, event] = args;
|
|
201
|
+
const trace = traceFor(session);
|
|
202
|
+
validateTurnBoundary(trace, event, fail);
|
|
203
|
+
const transition = validateCompactionEvent(trace, event, fail);
|
|
204
|
+
if (transition !== void 0) staged.set(event, {
|
|
205
|
+
session,
|
|
206
|
+
transition
|
|
207
|
+
});
|
|
208
|
+
}, { global: true });
|
|
209
|
+
}, { inject: ["sessions"] });
|
|
210
|
+
/**
|
|
211
|
+
* Register the compact invariant companion.
|
|
212
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
213
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
214
|
+
*/
|
|
215
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
216
|
+
//#endregion
|
|
217
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Branded } from '@hydraharness/harness-brand';
|
|
2
|
+
/** Stable identity shared by one compact start/summary/checkpoint/end transaction. */
|
|
3
|
+
export type CompactionId = Branded<'CompactionId'>;
|
|
4
|
+
/**
|
|
5
|
+
* Brand an implementation-minted compaction identity.
|
|
6
|
+
* @param id - opaque transaction identity.
|
|
7
|
+
* @returns the same string, branded; no validation is performed.
|
|
8
|
+
*/
|
|
9
|
+
export declare function CompactionId(id: string): CompactionId;
|
|
10
|
+
//# sourceMappingURL=brand.d.ts.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand an implementation-minted compaction identity.
|
|
3
|
+
* @param id - opaque transaction identity.
|
|
4
|
+
* @returns the same string, branded; no validation is performed.
|
|
5
|
+
*/
|
|
6
|
+
export function CompactionId(id) {
|
|
7
|
+
return id;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=brand.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction checkpoint provenance: the correlated source constructor and type
|
|
3
|
+
* every backend uses for its replacement user message, plus the predicate that
|
|
4
|
+
* recognizes persisted checkpoints.
|
|
5
|
+
*
|
|
6
|
+
* The seam itself lives in `@hydraharness/harness-compaction`, which re-exports these
|
|
7
|
+
* contracts; this module is a pure type/value/predicate outlet (no cordis
|
|
8
|
+
* imports, no module augmentation) so client and wire programs can name the
|
|
9
|
+
* checkpoint source without loading the host plugin's Context merges — the
|
|
10
|
+
* `hydra-commands/brand` shape.
|
|
11
|
+
*
|
|
12
|
+
* @module @hydraharness/harness-compaction/checkpoint
|
|
13
|
+
*/
|
|
14
|
+
import type { MessageSource } from '@hydraharness/harness-llm/message';
|
|
15
|
+
import type { CommandId } from '@hydraharness/harness-commands/brand';
|
|
16
|
+
import type { CompactionId } from './brand.ts';
|
|
17
|
+
declare const COMPACT_CHECKPOINT_MARKER: Readonly<{
|
|
18
|
+
readonly kind: "plugin";
|
|
19
|
+
readonly plugin: "compact";
|
|
20
|
+
}>;
|
|
21
|
+
/** Message provenance carried by a concrete compaction checkpoint. */
|
|
22
|
+
export type CompactionCheckpointSource = typeof COMPACT_CHECKPOINT_MARKER & {
|
|
23
|
+
readonly compactionId: CompactionId;
|
|
24
|
+
readonly sourceCommandId?: CommandId;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Create checkpoint provenance correlated with one compaction transaction.
|
|
28
|
+
* @param compactionId - owning compaction identity.
|
|
29
|
+
* @param sourceCommandId - initiating manual command, when present.
|
|
30
|
+
* @returns immutable checkpoint source.
|
|
31
|
+
*/
|
|
32
|
+
export declare function compactCheckpointSource(compactionId: CompactionId, sourceCommandId?: CommandId): CompactionCheckpointSource;
|
|
33
|
+
/**
|
|
34
|
+
* Test whether a persisted message source identifies a compaction checkpoint.
|
|
35
|
+
* @param source - source restored from a surface user message.
|
|
36
|
+
* @returns whether the source carries the backend-independent checkpoint marker.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isCompactCheckpointSource(source: MessageSource): boolean;
|
|
39
|
+
export {};
|
|
40
|
+
//# sourceMappingURL=checkpoint.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction checkpoint provenance: the correlated source constructor and type
|
|
3
|
+
* every backend uses for its replacement user message, plus the predicate that
|
|
4
|
+
* recognizes persisted checkpoints.
|
|
5
|
+
*
|
|
6
|
+
* The seam itself lives in `@hydraharness/harness-compaction`, which re-exports these
|
|
7
|
+
* contracts; this module is a pure type/value/predicate outlet (no cordis
|
|
8
|
+
* imports, no module augmentation) so client and wire programs can name the
|
|
9
|
+
* checkpoint source without loading the host plugin's Context merges — the
|
|
10
|
+
* `hydra-commands/brand` shape.
|
|
11
|
+
*
|
|
12
|
+
* @module @hydraharness/harness-compaction/checkpoint
|
|
13
|
+
*/
|
|
14
|
+
const COMPACT_CHECKPOINT_MARKER = Object.freeze({ kind: 'plugin', plugin: 'compact' });
|
|
15
|
+
/**
|
|
16
|
+
* Create checkpoint provenance correlated with one compaction transaction.
|
|
17
|
+
* @param compactionId - owning compaction identity.
|
|
18
|
+
* @param sourceCommandId - initiating manual command, when present.
|
|
19
|
+
* @returns immutable checkpoint source.
|
|
20
|
+
*/
|
|
21
|
+
export function compactCheckpointSource(compactionId, sourceCommandId) {
|
|
22
|
+
return Object.freeze({
|
|
23
|
+
...COMPACT_CHECKPOINT_MARKER,
|
|
24
|
+
compactionId,
|
|
25
|
+
...sourceCommandId === undefined ? {} : { sourceCommandId },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Test whether a persisted message source identifies a compaction checkpoint.
|
|
30
|
+
* @param source - source restored from a surface user message.
|
|
31
|
+
* @returns whether the source carries the backend-independent checkpoint marker.
|
|
32
|
+
*/
|
|
33
|
+
export function isCompactCheckpointSource(source) {
|
|
34
|
+
return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_MARKER.plugin;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=checkpoint.js.map
|