@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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Compaction Service Definition (`ctx.compaction`): providers decide when to
3
+ * compact and replace a history range with one summary node by subclassing
4
+ * {@link CompactionEngine}. This interface necessarily depends on session and LLM
5
+ * vocabulary; the rationale is in the
6
+ * [compaction Agent Note](../../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
7
+ * @module @hydraharness/harness-compaction
8
+ */
9
+ import { Context, Service } from '@hydraharness/cordis';
10
+ import type { Session } from '@hydraharness/harness-session';
11
+ import type { CommandId } from '@hydraharness/harness-commands/brand';
12
+ import type { CompactionResult } from './types.ts';
13
+ export type { CompactionResult } from './types.ts';
14
+ export { CompactionId } from './brand.ts';
15
+ export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts';
16
+ export { compactCheckpointSource, isCompactCheckpointSource } from './checkpoint.ts';
17
+ export type { CompactionCheckpointSource } from './checkpoint.ts';
18
+ /** Why automatic policy is asking a backend to consider compaction. */
19
+ export type CompactionTrigger = 'pressure' | 'context-overflow';
20
+ /** Expected failure classes for an explicit idle-session compaction request. */
21
+ export type ManualCompactionErrorCode = 'busy' | 'cancelled' | 'changed' | 'summary' | 'commit' | 'persistence';
22
+ /**
23
+ * Expected manual-compaction failure suitable for a direct human-command result.
24
+ * Shared durable-lock entry assertions may also throw the `busy` subtype from
25
+ * automatic compaction paths.
26
+ */
27
+ export declare class ManualCompactionError extends Error {
28
+ readonly code: ManualCompactionErrorCode;
29
+ readonly name = "ManualCompactionError";
30
+ /**
31
+ * Create one classified compaction failure.
32
+ * @param code - stable failure class; `busy` may originate from any compaction entry path.
33
+ * @param message - backend diagnostic retained as the Error message.
34
+ * @param options - optional original failure.
35
+ */
36
+ constructor(code: ManualCompactionErrorCode, message: string, options?: ErrorOptions);
37
+ }
38
+ /** Minimal agent context compaction needs without depending on the agent package. */
39
+ export interface CompactionAgentContext {
40
+ session: Session;
41
+ options: {
42
+ provider?: string;
43
+ model?: string;
44
+ };
45
+ }
46
+ /**
47
+ * Agent capability required to serialize an explicit idle-session compaction
48
+ * against driver turns. The durable `compaction/start` marker separately excludes
49
+ * other compaction transactions.
50
+ */
51
+ export interface ManualCompactAgentContext extends CompactionAgentContext {
52
+ /**
53
+ * Run a non-turn maintenance operation only while the agent is idle, withholding later
54
+ * waking input until it settles.
55
+ * @param task - operation whose fulfillment or rejection is preserved, with an agent-owned cancellation signal.
56
+ * @throws synchronously when the agent is already active.
57
+ * @returns the task promise.
58
+ */
59
+ runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;
60
+ }
61
+ declare module '@hydraharness/cordis' {
62
+ interface Context {
63
+ compaction: CompactionEngine;
64
+ }
65
+ }
66
+ /**
67
+ * Abstract compaction service. Implementations own trigger policy, retention,
68
+ * and summarization, and may consume a separate measurement service. A
69
+ * successful run replaces the selected surface span with one summary node and
70
+ * prevents concurrent compaction of the same session. The replacement user
71
+ * message uses {@link compactCheckpointSource} with the transaction identity
72
+ * so consumers recognize and correlate it independently of the backend. Load
73
+ * one implementation per context as `ctx.compaction`.
74
+ */
75
+ export declare abstract class CompactionEngine extends Service {
76
+ constructor(ctx: Context);
77
+ /**
78
+ * Consider automatic compaction for one explicit trigger. Pressure policy
79
+ * uses the latest durable routed request, while context-overflow policy may
80
+ * force a useful balanced reduction even below the normal threshold. Return
81
+ * `null` when no safe range can be compacted. A single oversized retained
82
+ * unit or request envelope cannot be repaired through surface compaction.
83
+ *
84
+ * @param agent - agent context owning the session surface and routing options.
85
+ * @param trigger - normal pressure or provider-confirmed context overflow.
86
+ * @param signal - cancellation signal; model-backed implementations must forward it.
87
+ * @returns the compaction result, or `null` if no compaction was needed.
88
+ */
89
+ abstract compactIfNeeded(agent: CompactionAgentContext, trigger: CompactionTrigger, signal: AbortSignal): Promise<CompactionResult | null>;
90
+ /**
91
+ * Explicitly compact useful history even below automatic pressure thresholds.
92
+ * Implementations synchronously start an idle task before any asynchronous
93
+ * work, select a useful range without writing on a no-op, then
94
+ * append a standalone `compaction/start` before summarization. That durable
95
+ * marker is the compaction lock until one `compaction/end` attempt. Later waking
96
+ * prompts remain accepted in FIFO order and start only after the optional
97
+ * durability checkpoint and idle-task settlement. Context injected while the
98
+ * summary runs may sit between the marker pair; only the selected span must
99
+ * remain stable.
100
+ *
101
+ * @param agent - idle agent whose durable history should be compacted.
102
+ * @param signal - cancellation scoped to this compaction request.
103
+ * @param sourceCommandId - initiating command identity for a manual compaction.
104
+ * @returns the compaction result, or `null` when no safe useful range exists.
105
+ * @throws {@link ManualCompactionError} for expected busy, agent-cancellation,
106
+ * changed-span, summarization/shrink, commit-stage, or persistence failures;
107
+ * an aborted request preserves its exact abort reason. Failed attempts remain
108
+ * visible in the log.
109
+ */
110
+ abstract compactNow(agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId): Promise<CompactionResult | null>;
111
+ /**
112
+ * Forcibly compact a range of surface nodes into a single summary node.
113
+ * `start` and `end` name an inclusive span by surface position, not numeric seq
114
+ * order; replacements can make visible seqs non-monotonic. Both edges must be
115
+ * balanced so assistant tool calls remain paired with their results. A model-
116
+ * backed implementation forwards cancellation and rejects active, missing,
117
+ * reversed, or unbalanced ranges. The target session is `agent.session`.
118
+ * Its replacement user message must use {@link compactCheckpointSource} with
119
+ * the transaction's `CompactionId`.
120
+ * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
121
+ * for the edge checks.
122
+ *
123
+ * @param start - first surface seq, inclusive.
124
+ * @param end - last surface seq, inclusive.
125
+ * @param agent - context whose session is mutated and whose routing options guide summarization.
126
+ * @param signal - optional cancellation; model-backed implementations must forward it.
127
+ * @throws when compaction is active or the range is missing, reversed, or unbalanced.
128
+ * @returns the appended event seqs, summary, replaced range, and token accounting.
129
+ */
130
+ abstract compactRegion(start: number, end: number, agent: CompactionAgentContext, signal?: AbortSignal): Promise<CompactionResult>;
131
+ }
132
+ export default CompactionEngine;
133
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Compaction Service Definition (`ctx.compaction`): providers decide when to
3
+ * compact and replace a history range with one summary node by subclassing
4
+ * {@link CompactionEngine}. This interface necessarily depends on session and LLM
5
+ * vocabulary; the rationale is in the
6
+ * [compaction Agent Note](../../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
7
+ * @module @hydraharness/harness-compaction
8
+ */
9
+ import { Service } from '@hydraharness/cordis';
10
+ export { CompactionId } from "./brand.js";
11
+ export { toolPairingBalancedAfter, toolPairingBalancedBefore } from "./tool-pairing.js";
12
+ // The checkpoint source constructor and predicate are declared on the cordis-free
13
+ // `./checkpoint` leaf so client and wire programs can name them without this
14
+ // root's Context merge; the root stays the host-side entry point for both.
15
+ export { compactCheckpointSource, isCompactCheckpointSource } from "./checkpoint.js";
16
+ /**
17
+ * Expected manual-compaction failure suitable for a direct human-command result.
18
+ * Shared durable-lock entry assertions may also throw the `busy` subtype from
19
+ * automatic compaction paths.
20
+ */
21
+ export class ManualCompactionError extends Error {
22
+ code;
23
+ name = 'ManualCompactionError';
24
+ /**
25
+ * Create one classified compaction failure.
26
+ * @param code - stable failure class; `busy` may originate from any compaction entry path.
27
+ * @param message - backend diagnostic retained as the Error message.
28
+ * @param options - optional original failure.
29
+ */
30
+ constructor(code, message, options) {
31
+ super(message, options);
32
+ this.code = code;
33
+ }
34
+ }
35
+ /**
36
+ * Abstract compaction service. Implementations own trigger policy, retention,
37
+ * and summarization, and may consume a separate measurement service. A
38
+ * successful run replaces the selected surface span with one summary node and
39
+ * prevents concurrent compaction of the same session. The replacement user
40
+ * message uses {@link compactCheckpointSource} with the transaction identity
41
+ * so consumers recognize and correlate it independently of the backend. Load
42
+ * one implementation per context as `ctx.compaction`.
43
+ */
44
+ export class CompactionEngine extends Service {
45
+ constructor(ctx) {
46
+ super(ctx, 'compaction');
47
+ }
48
+ }
49
+ export default CompactionEngine;
50
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,13 @@
1
+ /** Package-owned compaction log-stream invariants. @module @hydraharness/harness-compaction/invariant */
2
+ import type { Context } from '@hydraharness/cordis';
3
+ /** Cordis companion plugin name. */
4
+ export declare const name = "compaction-invariant";
5
+ /** Service required before the companion can reserve package ownership. */
6
+ export declare const inject: string[];
7
+ /**
8
+ * Register the compact invariant companion.
9
+ * @param ctx - Cordis context carrying the invariant service.
10
+ * @returns the installed registration's disposer after setup succeeds.
11
+ */
12
+ export declare const apply: (ctx: Context) => Promise<() => void>;
13
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,258 @@
1
+ /** Package-owned compaction log-stream invariants. @module @hydraharness/harness-compaction/invariant */
2
+ import { isReplacementSurfaceEvent } from '@hydraharness/harness-session';
3
+ import { isCompactCheckpointSource } from "./checkpoint.js";
4
+ const PACKAGE_NAME = '@hydraharness/harness-compaction';
5
+ /** Cordis companion plugin name. */
6
+ export const name = 'compaction-invariant';
7
+ /** Service required before the companion can reserve package ownership. */
8
+ export const inject = ['invariants'];
9
+ /** Require a durable opaque identity to be a non-empty string. */
10
+ function validateId(value, label, fail) {
11
+ if (typeof value !== 'string' || value.length === 0)
12
+ fail(`${label} must be a non-empty string`);
13
+ }
14
+ /** Keep the optional initiating command identity stable across one transaction. */
15
+ function validateSourceCommandId(eventType, value, expected, fail) {
16
+ if (value !== undefined)
17
+ validateId(value, `${eventType} sourceCommandId`, fail);
18
+ if (value !== expected) {
19
+ fail(`${eventType} sourceCommandId ${String(value)} does not match compaction/start sourceCommandId ${String(expected)}`);
20
+ }
21
+ }
22
+ /** Validate one replacement checkpoint against its open compaction transaction. */
23
+ function validateCheckpoint(trace, event, fail) {
24
+ const source = event.data.source;
25
+ validateId(source.compactionId, 'compaction checkpoint compactionId', fail);
26
+ if (source.sourceCommandId !== undefined) {
27
+ validateId(source.sourceCommandId, 'compaction checkpoint sourceCommandId', fail);
28
+ }
29
+ const open = trace.compaction;
30
+ if (open === undefined)
31
+ fail('compaction checkpoint has no matching compaction/start');
32
+ if (source.compactionId !== open.compactionId) {
33
+ fail(`compaction checkpoint id ${source.compactionId} does not match compaction/start id ${open.compactionId}`);
34
+ }
35
+ validateSourceCommandId('compaction checkpoint', source.sourceCommandId, open.sourceCommandId, fail);
36
+ }
37
+ /** Compaction starts still unmatched when a later seed boundary made them stale. */
38
+ function inheritedOrphanStartSeqs(events) {
39
+ const stale = new Set();
40
+ let openStartSeq;
41
+ for (const event of events) {
42
+ if (event.type === 'compaction/start') {
43
+ openStartSeq = event.seq;
44
+ }
45
+ else if (event.type === 'compaction/end') {
46
+ openStartSeq = undefined;
47
+ }
48
+ else if (event.type === 'session/end-seed') {
49
+ if (openStartSeq !== undefined)
50
+ stale.add(openStartSeq);
51
+ openStartSeq = undefined;
52
+ }
53
+ }
54
+ return stale;
55
+ }
56
+ /** Keep every live compaction bracket on one side of each turn boundary. */
57
+ function validateTurnBoundary(trace, event, fail) {
58
+ if ((event.type !== 'turn/start' && event.type !== 'turn/end')
59
+ || trace.compaction === undefined)
60
+ return;
61
+ const owner = trace.compaction.turn === null
62
+ ? 'standalone compaction'
63
+ : `compaction for turn ${trace.compaction.turn}`;
64
+ fail(`${event.type} cannot cross an open ${owner}`);
65
+ }
66
+ /** Advance the committed turn cursor after its boundary has been accepted. */
67
+ function applyTurnBoundary(trace, event) {
68
+ if (event.type === 'turn/start') {
69
+ trace.openTurn = event.data.turn;
70
+ return true;
71
+ }
72
+ if (event.type === 'turn/end') {
73
+ trace.openTurn = null;
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ /** Require a numbered bracket inside its exact turn, or a standalone bracket between turns. */
79
+ function validateOwner(owner, openTurn, eventType, fail) {
80
+ if (owner === null) {
81
+ if (openTurn !== null)
82
+ fail(`${eventType} is standalone but turn ${openTurn} is open`);
83
+ return;
84
+ }
85
+ if (openTurn === null)
86
+ fail(`${eventType} for turn ${owner} appended outside any open turn`);
87
+ if (owner !== openTurn)
88
+ fail(`${eventType} names turn ${owner} but open turn is ${openTurn}`);
89
+ }
90
+ /** Validate one compaction event without advancing committed trace state. */
91
+ function validateCompactionEvent(trace, event, fail) {
92
+ if (event.type === 'session/end-seed')
93
+ return { kind: 'end-seed' };
94
+ if (event.type === 'user/message'
95
+ && isReplacementSurfaceEvent(event)
96
+ && isCompactCheckpointSource(event.data.source)) {
97
+ validateCheckpoint(trace, event, fail);
98
+ return undefined;
99
+ }
100
+ if (event.type !== 'compaction/start' && event.type !== 'compaction/summary' && event.type !== 'compaction/end') {
101
+ return undefined;
102
+ }
103
+ const open = trace.compaction;
104
+ if (event.type === 'compaction/start') {
105
+ validateId(event.data.compactionId, 'compaction/start compactionId', fail);
106
+ if (event.data.sourceCommandId !== undefined) {
107
+ validateId(event.data.sourceCommandId, 'compaction/start sourceCommandId', fail);
108
+ }
109
+ if (open !== undefined) {
110
+ const owner = open.turn === null ? 'standalone compaction' : `turn ${open.turn}`;
111
+ fail(`compaction/start while ${owner} is still compacting`);
112
+ }
113
+ validateOwner(event.data.turn, trace.openTurn, event.type, fail);
114
+ return {
115
+ kind: 'start',
116
+ compactionId: event.data.compactionId,
117
+ sourceCommandId: event.data.sourceCommandId,
118
+ startSeq: event.seq,
119
+ turn: event.data.turn,
120
+ };
121
+ }
122
+ if (event.type === 'compaction/summary') {
123
+ validateId(event.data.compactionId, 'compaction/summary compactionId', fail);
124
+ if (event.data.sourceCommandId !== undefined) {
125
+ validateId(event.data.sourceCommandId, 'compaction/summary sourceCommandId', fail);
126
+ }
127
+ if (open === undefined)
128
+ fail('compaction/summary has no matching compaction/start');
129
+ if (event.data.compactionId !== open.compactionId) {
130
+ fail(`compaction/summary id ${event.data.compactionId} does not match compaction/start id ${open.compactionId}`);
131
+ }
132
+ validateSourceCommandId('compaction/summary', event.data.sourceCommandId, open.sourceCommandId, fail);
133
+ validateOwner(open.turn, trace.openTurn, event.type, fail);
134
+ if (open.summarized)
135
+ fail('compaction/summary repeated within one compaction');
136
+ const seqs = event.data.shadowedSeqs;
137
+ if (seqs.length === 0)
138
+ fail('compaction/summary shadowedSeqs must be non-empty');
139
+ if (seqs[0] !== event.data.shadowedRange.start || seqs.at(-1) !== event.data.shadowedRange.end) {
140
+ fail('compaction/summary shadowedRange must match the first and last shadowedSeqs');
141
+ }
142
+ if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) {
143
+ fail('compaction/summary shadowedTokenCount must be a non-negative safe integer');
144
+ }
145
+ return {
146
+ kind: 'summary',
147
+ compactionId: open.compactionId,
148
+ sourceCommandId: open.sourceCommandId,
149
+ startSeq: open.startSeq,
150
+ turn: open.turn,
151
+ };
152
+ }
153
+ validateId(event.data.compactionId, 'compaction/end compactionId', fail);
154
+ if (event.data.sourceCommandId !== undefined) {
155
+ validateId(event.data.sourceCommandId, 'compaction/end sourceCommandId', fail);
156
+ }
157
+ if (open === undefined)
158
+ fail('compaction/end has no matching compaction/start');
159
+ if (event.data.compactionId !== open.compactionId) {
160
+ fail(`compaction/end id ${event.data.compactionId} does not match compaction/start id ${open.compactionId}`);
161
+ }
162
+ validateSourceCommandId('compaction/end', event.data.sourceCommandId, open.sourceCommandId, fail);
163
+ if (event.data.turn !== open.turn) {
164
+ fail(`compaction/end owner ${String(event.data.turn)} does not match compaction/start owner ${String(open.turn)}`);
165
+ }
166
+ validateOwner(open.turn, trace.openTurn, event.type, fail);
167
+ if (event.data.error === undefined && !open.summarized) {
168
+ fail('successful compaction/end requires one compaction/summary');
169
+ }
170
+ return { kind: 'end' };
171
+ }
172
+ /** Apply one committed compaction transition. */
173
+ function applyCompactionTransition(transition) {
174
+ if (transition.kind === 'start') {
175
+ return {
176
+ compactionId: transition.compactionId,
177
+ sourceCommandId: transition.sourceCommandId,
178
+ startSeq: transition.startSeq,
179
+ turn: transition.turn,
180
+ summarized: false,
181
+ };
182
+ }
183
+ if (transition.kind === 'summary') {
184
+ return {
185
+ compactionId: transition.compactionId,
186
+ sourceCommandId: transition.sourceCommandId,
187
+ startSeq: transition.startSeq,
188
+ turn: transition.turn,
189
+ summarized: true,
190
+ };
191
+ }
192
+ return undefined;
193
+ }
194
+ /** Install compaction start/summary/end checks. */
195
+ // Event owners keep precommit staging local so their vocabularies never move into a central helper.
196
+ /* jscpd:ignore-start */
197
+ const install = Object.assign((ctx, fail) => {
198
+ const traces = new WeakMap();
199
+ const staged = new WeakMap();
200
+ const seed = (session) => {
201
+ const trace = { openTurn: null, compaction: undefined };
202
+ traces.set(session, trace);
203
+ const staleOrphanStartSeqs = inheritedOrphanStartSeqs(session.events);
204
+ for (const event of session.events) {
205
+ // Constructor-seed repair boundaries can precede the end-seed marker
206
+ // that proves an inherited orphan stale. Replay that inherited prefix
207
+ // without letting the soon-to-be-cleared bracket veto its repair.
208
+ if (trace.compaction === undefined
209
+ || !staleOrphanStartSeqs.has(trace.compaction.startSeq)) {
210
+ validateTurnBoundary(trace, event, fail);
211
+ }
212
+ const transition = validateCompactionEvent(trace, event, fail);
213
+ if (transition !== undefined)
214
+ trace.compaction = applyCompactionTransition(transition);
215
+ applyTurnBoundary(trace, event);
216
+ }
217
+ return trace;
218
+ };
219
+ const traceFor = (session) => traces.get(session) ?? seed(session);
220
+ for (const session of ctx.sessions.list())
221
+ seed(session);
222
+ ctx.on('session/created', (session) => { seed(session); }, { global: true });
223
+ ctx.on('session/event', (session, event) => {
224
+ const trace = traceFor(session);
225
+ validateTurnBoundary(trace, event, fail);
226
+ if (applyTurnBoundary(trace, event))
227
+ return;
228
+ if (event.type !== 'session/end-seed'
229
+ && event.type !== 'compaction/start'
230
+ && event.type !== 'compaction/summary'
231
+ && event.type !== 'compaction/end')
232
+ return;
233
+ const candidate = staged.get(event);
234
+ /* v8 ignore next -- internal/dispatch stages every compaction event */
235
+ if (candidate === undefined || candidate.session !== session)
236
+ return fail('compaction event published without pre-commit validation');
237
+ staged.delete(event);
238
+ trace.compaction = applyCompactionTransition(candidate.transition);
239
+ }, { global: true });
240
+ ctx.on('internal/dispatch', (_mode, eventName, args) => {
241
+ if (eventName !== 'session/event')
242
+ return;
243
+ const [session, event] = args;
244
+ const trace = traceFor(session);
245
+ validateTurnBoundary(trace, event, fail);
246
+ const transition = validateCompactionEvent(trace, event, fail);
247
+ if (transition !== undefined)
248
+ staged.set(event, { session, transition });
249
+ }, { global: true });
250
+ }, { inject: ['sessions'] });
251
+ /* jscpd:ignore-end */
252
+ /**
253
+ * Register the compact invariant companion.
254
+ * @param ctx - Cordis context carrying the invariant service.
255
+ * @returns the installed registration's disposer after setup succeeds.
256
+ */
257
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
258
+ //# sourceMappingURL=invariant.js.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Tool-pairing balance over a session surface. Compaction changes surface
3
+ * positions, so safe cuts are derived from tool-call/result content in current
4
+ * surface order rather than step markers.
5
+ * @module @hydraharness/harness-compaction/tool-pairing
6
+ */
7
+ import type { Session } from '@hydraharness/harness-session';
8
+ /**
9
+ * Whether the cut immediately before a current surface sequence is tool-pairing balanced.
10
+ * @param session - session whose surface is checked.
11
+ * @param seq - event sequence whose leading cut is checked.
12
+ * @returns true when no unanswered tool call crosses the cut.
13
+ * @throws when the seq is absent from the current surface, a surface sequence has no
14
+ * matching log event, or a tool result has no preceding open call.
15
+ */
16
+ export declare function toolPairingBalancedBefore(session: Session, seq: number): boolean;
17
+ /**
18
+ * Whether the cut immediately after a current surface sequence is tool-pairing balanced.
19
+ * @param session - session whose surface is checked.
20
+ * @param seq - event sequence whose trailing cut is checked.
21
+ * @returns true when no unanswered tool call crosses the cut.
22
+ * @throws when the seq is absent from the current surface, a surface sequence has no
23
+ * matching log event, or a tool result has no preceding open call.
24
+ */
25
+ export declare function toolPairingBalancedAfter(session: Session, seq: number): boolean;
26
+ //# sourceMappingURL=tool-pairing.d.ts.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Tool-pairing balance over a session surface. Compaction changes surface
3
+ * positions, so safe cuts are derived from tool-call/result content in current
4
+ * surface order rather than step markers.
5
+ * @module @hydraharness/harness-compaction/tool-pairing
6
+ */
7
+ const balanceCacheBySession = new WeakMap();
8
+ /** Return how one surface event changes the in-progress tool-call count. */
9
+ function eventDelta(event) {
10
+ switch (event.type) {
11
+ case 'assistant/message':
12
+ return event.data.message.content.filter(block => block.type === 'tool-call').length;
13
+ case 'tool/result':
14
+ return -1;
15
+ default:
16
+ return 0;
17
+ }
18
+ }
19
+ /** Read and validate the event named by a surface sequence. */
20
+ function eventForSeq(events, seq) {
21
+ const event = events[seq];
22
+ if (event === undefined || event.seq !== seq) {
23
+ throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`);
24
+ }
25
+ return event;
26
+ }
27
+ /** Fold surface sequences not yet in the cache into its balance state. */
28
+ function extendCache(session, cache, seqs) {
29
+ const processed = cache.cutBalanced.length - 1;
30
+ const tail = seqs.slice(processed);
31
+ // Validate the unseen tail before mutating the live cache, so a corrupt
32
+ // append cannot leave a partially advanced state behind.
33
+ const events = session.events;
34
+ const pendingCuts = [];
35
+ let inProgressToolCalls = cache.inProgressToolCalls;
36
+ for (const seq of tail) {
37
+ inProgressToolCalls += eventDelta(eventForSeq(events, seq));
38
+ if (inProgressToolCalls < 0) {
39
+ throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`);
40
+ }
41
+ pendingCuts.push(inProgressToolCalls === 0);
42
+ }
43
+ tail.forEach((seq, offset) => cache.indexBySeq.set(seq, processed + offset));
44
+ cache.cutBalanced = cache.cutBalanced.concat(pendingCuts);
45
+ cache.inProgressToolCalls = inProgressToolCalls;
46
+ return cache;
47
+ }
48
+ /** Return balance state synchronized with the current session surface. */
49
+ function balanceCache(session) {
50
+ const surface = session.surface;
51
+ const seqs = surface.nodes;
52
+ const generation = surface.replaceGeneration;
53
+ const cached = balanceCacheBySession.get(session);
54
+ if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > seqs.length) {
55
+ // A rebuild is the same fold started from the empty-surface state, whose
56
+ // single leading cut is trivially balanced.
57
+ const rebuilt = extendCache(session, {
58
+ generation,
59
+ cutBalanced: [true],
60
+ indexBySeq: new Map(),
61
+ inProgressToolCalls: 0,
62
+ }, seqs);
63
+ balanceCacheBySession.set(session, rebuilt);
64
+ return rebuilt;
65
+ }
66
+ if (cached.cutBalanced.length - 1 < seqs.length)
67
+ 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 === undefined ? undefined : cache.cutBalanced[index + offset];
74
+ if (balanced === undefined) {
75
+ throw new Error(`tool-pairing balance: surface seq ${seq} not found`);
76
+ }
77
+ return balanced;
78
+ }
79
+ /**
80
+ * Whether the cut immediately before a current surface sequence is tool-pairing balanced.
81
+ * @param session - session whose surface is checked.
82
+ * @param seq - event sequence whose leading cut is checked.
83
+ * @returns true when no unanswered tool call crosses the cut.
84
+ * @throws when the seq is absent from the current surface, a surface sequence has no
85
+ * matching log event, or a tool result has no preceding open call.
86
+ */
87
+ export function toolPairingBalancedBefore(session, seq) {
88
+ return cutBalance(balanceCache(session), seq, 0);
89
+ }
90
+ /**
91
+ * Whether the cut immediately after a current surface sequence is tool-pairing balanced.
92
+ * @param session - session whose surface is checked.
93
+ * @param seq - event sequence whose trailing cut is checked.
94
+ * @returns true when no unanswered tool call crosses the cut.
95
+ * @throws when the seq is absent from the current surface, a surface sequence has no
96
+ * matching log event, or a tool result has no preceding open call.
97
+ */
98
+ export function toolPairingBalancedAfter(session, seq) {
99
+ return cutBalance(balanceCache(session), seq, 1);
100
+ }
101
+ //# sourceMappingURL=tool-pairing.js.map