@modelprofile.com/flexharness 10.23.0 → 10.24.1

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.
Files changed (83) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.flexharness.admission.d.ts +17 -0
  3. package/dist_ts/classes.flexharness.admission.js +263 -0
  4. package/dist_ts/classes.flexharness.callbacks.d.ts +38 -0
  5. package/dist_ts/classes.flexharness.callbacks.js +313 -0
  6. package/dist_ts/classes.flexharness.compaction.d.ts +25 -0
  7. package/dist_ts/classes.flexharness.compaction.js +87 -0
  8. package/dist_ts/classes.flexharness.configurations.d.ts +11 -0
  9. package/dist_ts/classes.flexharness.configurations.js +137 -0
  10. package/dist_ts/classes.flexharness.core.d.ts +143 -0
  11. package/dist_ts/classes.flexharness.core.js +1538 -0
  12. package/dist_ts/classes.flexharness.d.ts +2 -437
  13. package/dist_ts/classes.flexharness.deletion.d.ts +31 -0
  14. package/dist_ts/classes.flexharness.deletion.js +396 -0
  15. package/dist_ts/classes.flexharness.imports.d.ts +24 -0
  16. package/dist_ts/classes.flexharness.imports.js +345 -0
  17. package/dist_ts/classes.flexharness.js +119 -9776
  18. package/dist_ts/classes.flexharness.lifecycle.d.ts +47 -0
  19. package/dist_ts/classes.flexharness.lifecycle.js +625 -0
  20. package/dist_ts/classes.flexharness.permissions.d.ts +19 -0
  21. package/dist_ts/classes.flexharness.permissions.js +450 -0
  22. package/dist_ts/classes.flexharness.projects.d.ts +55 -0
  23. package/dist_ts/classes.flexharness.projects.js +575 -0
  24. package/dist_ts/classes.flexharness.prompts.d.ts +44 -0
  25. package/dist_ts/classes.flexharness.prompts.js +659 -0
  26. package/dist_ts/classes.flexharness.providers.d.ts +18 -0
  27. package/dist_ts/classes.flexharness.providers.js +267 -0
  28. package/dist_ts/classes.flexharness.results.d.ts +26 -0
  29. package/dist_ts/classes.flexharness.results.js +89 -0
  30. package/dist_ts/classes.flexharness.reversions.d.ts +52 -0
  31. package/dist_ts/classes.flexharness.reversions.js +1306 -0
  32. package/dist_ts/classes.flexharness.runs.d.ts +63 -0
  33. package/dist_ts/classes.flexharness.runs.js +1058 -0
  34. package/dist_ts/classes.flexharness.sessions.d.ts +20 -0
  35. package/dist_ts/classes.flexharness.sessions.js +381 -0
  36. package/dist_ts/classes.flexharness.slashcommands.d.ts +40 -0
  37. package/dist_ts/classes.flexharness.slashcommands.js +458 -0
  38. package/dist_ts/classes.flexharness.subagents.d.ts +31 -0
  39. package/dist_ts/classes.flexharness.subagents.js +690 -0
  40. package/dist_ts/classes.flexharness.tooldispatch.d.ts +53 -0
  41. package/dist_ts/classes.flexharness.tooldispatch.js +418 -0
  42. package/dist_ts/interfaces.flexharness.d.ts +455 -0
  43. package/dist_ts/interfaces.flexharness.js +2 -0
  44. package/dist_ts/utils.flexharness.d.ts +19 -0
  45. package/dist_ts/utils.flexharness.js +83 -0
  46. package/dist_ts_compaction/plugins.d.ts +2 -2
  47. package/dist_ts_compaction/plugins.js +3 -3
  48. package/dist_ts_compaction/smartagent.compaction.js +6 -5
  49. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +9 -0
  50. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +13 -1
  51. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +2 -2
  52. package/dist_ts_stores_nosqldb/classes.recordstore.js +3 -3
  53. package/package.json +3 -3
  54. package/readme.md +1 -1
  55. package/ts/00_commitinfo_data.ts +1 -1
  56. package/ts/classes.flexharness.admission.ts +331 -0
  57. package/ts/classes.flexharness.callbacks.ts +352 -0
  58. package/ts/classes.flexharness.compaction.ts +167 -0
  59. package/ts/classes.flexharness.configurations.ts +167 -0
  60. package/ts/classes.flexharness.core.ts +1830 -0
  61. package/ts/classes.flexharness.deletion.ts +616 -0
  62. package/ts/classes.flexharness.imports.ts +414 -0
  63. package/ts/classes.flexharness.lifecycle.ts +786 -0
  64. package/ts/classes.flexharness.permissions.ts +535 -0
  65. package/ts/classes.flexharness.projects.ts +1006 -0
  66. package/ts/classes.flexharness.prompts.ts +857 -0
  67. package/ts/classes.flexharness.providers.ts +322 -0
  68. package/ts/classes.flexharness.results.ts +98 -0
  69. package/ts/classes.flexharness.reversions.ts +1684 -0
  70. package/ts/classes.flexharness.runs.ts +1204 -0
  71. package/ts/classes.flexharness.sessions.ts +483 -0
  72. package/ts/classes.flexharness.slashcommands.ts +705 -0
  73. package/ts/classes.flexharness.subagents.ts +860 -0
  74. package/ts/classes.flexharness.tooldispatch.ts +475 -0
  75. package/ts/classes.flexharness.ts +147 -12567
  76. package/ts/interfaces.flexharness.ts +517 -0
  77. package/ts/readme.md +1 -1
  78. package/ts/utils.flexharness.ts +121 -0
  79. package/ts_compaction/plugins.ts +2 -2
  80. package/ts_compaction/smartagent.compaction.ts +5 -4
  81. package/ts_stores_nosqldb/classes.nosqldbstores.ts +13 -0
  82. package/ts_stores_nosqldb/classes.recordstore.ts +5 -5
  83. package/ts_stores_nosqldb/readme.md +7 -0
@@ -0,0 +1,352 @@
1
+ import * as plugins from './plugins.js';
2
+ import { FlexHarnessCallbackOverflowError } from './errors.js';
3
+ import {
4
+ FLEX_TOOL_CATALOG_TOOL_NAMES,
5
+ type IFlexReasoningMessagePart,
6
+ type IFlexToolMessagePart,
7
+ type IFlexUsage,
8
+ type TFlexAgentToolCallUpdateEvent,
9
+ type TFlexAgentToolCallFinishEvent,
10
+ type TFlexMessagePart,
11
+ } from './interfaces.js';
12
+ import { normalizeJsonValue } from './utils.json.js';
13
+ import { jsonBytes } from './utils.transfer.js';
14
+ import {
15
+ observeSessionMetricsCall,
16
+ settleSessionMetricsOperation,
17
+ type ISessionMetricsOperation,
18
+ } from './utils.sessionmetrics.js';
19
+ import type { IStoredSessionState, IStorageState, IActiveRun } from './interfaces.flexharness.js';
20
+ import type { FlexHarnessCore } from './classes.flexharness.core.js';
21
+
22
+ /** The model stream callbacks of a run: text, reasoning, tool, retry and usage events. */
23
+ export class FlexHarnessRunCallbacks<TScope> {
24
+ constructor(private readonly core: FlexHarnessCore<TScope>) {}
25
+
26
+ public onTextDelta(state: IStorageState, sessionId: string, delta: string): void {
27
+ const run = this.callbackRun(state, sessionId);
28
+ if (!run || !delta) return;
29
+ const lastPart = run.callbackParts.at(-1);
30
+ const created = lastPart?.type !== 'text';
31
+ const part = lastPart?.type === 'text'
32
+ ? lastPart
33
+ : { partId: plugins.crypto.randomUUID(), type: 'text' as const, text: '' };
34
+ const accounting = this.textAppendAccounting(run, part, delta, created);
35
+ if (!accounting) return;
36
+ if (!this.reserveCallbackCapacity(
37
+ run,
38
+ created ? 2 : 1,
39
+ accounting.textUtf8Bytes - accounting.baseTextUtf8Bytes,
40
+ created ? 1 : 0,
41
+ )) return;
42
+ if (created) {
43
+ run.callbackParts.push(part);
44
+ run.partTextUtf8Bytes.set(part.partId, 0);
45
+ this.core.emitPartEvent(run, 'part.started', part);
46
+ }
47
+ part.text += delta;
48
+ run.partTextUtf8Bytes.set(part.partId, accounting.textUtf8Bytes);
49
+ this.core.emitPartDeltaEvent(run, part, delta, accounting);
50
+ }
51
+
52
+ public onReasoningStart(state: IStorageState, sessionId: string, id: string): void {
53
+ const run = this.callbackRun(state, sessionId);
54
+ if (!run || run.reasoningPartIds.has(id)) return;
55
+ if (!this.reserveCallbackCapacity(run, 1, 0, 1)) return;
56
+ const part: IFlexReasoningMessagePart = {
57
+ partId: plugins.crypto.randomUUID(),
58
+ type: 'reasoning',
59
+ text: '',
60
+ status: 'running',
61
+ startedAt: new Date().toISOString(),
62
+ };
63
+ run.reasoningPartIds.set(id, part.partId);
64
+ run.callbackParts.push(part);
65
+ run.partTextUtf8Bytes.set(part.partId, 0);
66
+ this.core.emitPartEvent(run, 'part.started', part);
67
+ }
68
+
69
+ public onReasoningDelta(state: IStorageState, sessionId: string, id: string, delta: string): void {
70
+ const run = this.callbackRun(state, sessionId);
71
+ if (!run || !delta) return;
72
+ if (!run.reasoningPartIds.has(id)) this.onReasoningStart(state, sessionId, id);
73
+ const partId = run.reasoningPartIds.get(id);
74
+ const part = run.callbackParts.find((entry) => entry.partId === partId && entry.type === 'reasoning');
75
+ if (!part || part.type !== 'reasoning' || part.status !== 'running') return;
76
+ const accounting = this.textAppendAccounting(run, part, delta);
77
+ if (!accounting) return;
78
+ if (!this.reserveCallbackCapacity(
79
+ run,
80
+ 1,
81
+ accounting.textUtf8Bytes - accounting.baseTextUtf8Bytes,
82
+ 0,
83
+ )) return;
84
+ part.text += delta;
85
+ run.partTextUtf8Bytes.set(part.partId, accounting.textUtf8Bytes);
86
+ this.core.emitPartDeltaEvent(run, part, delta, accounting);
87
+ }
88
+
89
+ public onReasoningEnd(state: IStorageState, sessionId: string, id: string, text: string): void {
90
+ const run = this.callbackRun(state, sessionId);
91
+ if (!run) return;
92
+ if (!run.reasoningPartIds.has(id)) this.onReasoningStart(state, sessionId, id);
93
+ const partId = run.reasoningPartIds.get(id);
94
+ const part = run.callbackParts.find((entry) => entry.partId === partId && entry.type === 'reasoning');
95
+ if (!part || part.type !== 'reasoning' || part.status !== 'running') return;
96
+ const missingText = part.text ? '' : text;
97
+ const missingTextUtf8Bytes = Buffer.byteLength(missingText, 'utf8');
98
+ if (!this.reserveCallbackCapacity(run, 1, missingTextUtf8Bytes, 0)) return;
99
+ if (missingText) {
100
+ part.text = missingText;
101
+ run.partTextUtf8Bytes.set(part.partId, missingTextUtf8Bytes);
102
+ }
103
+ part.status = 'completed';
104
+ part.completedAt = new Date().toISOString();
105
+ this.core.emitPartEvent(run, 'part.completed', part);
106
+ }
107
+
108
+ private textAppendAccounting(
109
+ run: IActiveRun,
110
+ part: Extract<TFlexMessagePart, { type: 'text' | 'reasoning' }>,
111
+ delta: string,
112
+ created = false,
113
+ ): { baseTextUtf8Bytes: number; textUtf8Bytes: number } | undefined {
114
+ const baseTextUtf8Bytes = created ? 0 : run.partTextUtf8Bytes.get(part.partId);
115
+ if (baseTextUtf8Bytes === undefined) {
116
+ throw new Error('Flex part text byte accounting is unavailable.');
117
+ }
118
+ const trailingCodeUnit = part.text.charCodeAt(part.text.length - 1);
119
+ const leadingCodeUnit = delta.charCodeAt(0);
120
+ const joinsSurrogatePair = trailingCodeUnit >= 0xD800
121
+ && trailingCodeUnit <= 0xDBFF
122
+ && leadingCodeUnit >= 0xDC00
123
+ && leadingCodeUnit <= 0xDFFF;
124
+ const textUtf8Bytes = baseTextUtf8Bytes
125
+ + Buffer.byteLength(delta, 'utf8')
126
+ - (joinsSurrogatePair ? 2 : 0);
127
+ if (!Number.isSafeInteger(textUtf8Bytes)) {
128
+ this.failCallback(run, this.core.trustInternalError(new FlexHarnessCallbackOverflowError(
129
+ 'Callback text byte accounting exceeded the safe integer range.',
130
+ )));
131
+ return undefined;
132
+ }
133
+ return { baseTextUtf8Bytes, textUtf8Bytes };
134
+ }
135
+
136
+ public onToolStart(
137
+ state: IStorageState,
138
+ sessionId: string,
139
+ event: { toolCallId: string; toolName: string; input: unknown; parentToolCallId?: string },
140
+ ): void {
141
+ const run = this.callbackRun(state, sessionId);
142
+ if (!run || run.toolPartIds.has(event.toolCallId)) return;
143
+ try {
144
+ // An execute call is the call of the catalog method it names.
145
+ const target = event.toolName === FLEX_TOOL_CATALOG_TOOL_NAMES.execute
146
+ ? run.toolCatalog?.callTarget(event.input)
147
+ : undefined;
148
+ const input = normalizeJsonValue(target ? target.params : event.input, this.core.toolOutputLimits);
149
+ if (!this.reserveCallbackCapacity(run, 1, jsonBytes(input), 1)) return;
150
+ const part: IFlexToolMessagePart = {
151
+ partId: plugins.crypto.randomUUID(),
152
+ type: 'tool',
153
+ toolCallId: this.core.boundedIdentifier(event.toolCallId, 'toolCallId'),
154
+ toolName: this.core.boundedIdentifier(target ? target.method : event.toolName, 'toolName'),
155
+ status: 'running',
156
+ input,
157
+ ...(event.parentToolCallId === undefined
158
+ ? {}
159
+ : { parentToolCallId: this.core.boundedIdentifier(event.parentToolCallId, 'parentToolCallId') }),
160
+ startedAt: new Date().toISOString(),
161
+ };
162
+ run.toolPartIds.set(event.toolCallId, part.partId);
163
+ run.callbackParts.push(part);
164
+ this.core.emitPartEvent(run, 'part.started', part);
165
+ } catch (error) {
166
+ this.failCallback(run, this.core.projectExternalError(run, error, 'toolCallback'));
167
+ }
168
+ }
169
+
170
+ public onToolFinish(
171
+ state: IStorageState,
172
+ sessionId: string,
173
+ event: TFlexAgentToolCallFinishEvent,
174
+ ): void {
175
+ const run = this.callbackRun(state, sessionId);
176
+ if (!run) return;
177
+ if (!run.toolPartIds.has(event.toolCallId)) this.onToolStart(state, sessionId, event);
178
+ const partId = run.toolPartIds.get(event.toolCallId);
179
+ const part = run.callbackParts.find((entry) => entry.partId === partId && entry.type === 'tool');
180
+ if (!part || part.type !== 'tool' || part.status !== 'running') return;
181
+ try {
182
+ const trustedExecutionError = run.trustedToolExecutionErrors.get(event.toolCallId);
183
+ run.trustedToolExecutionErrors.delete(event.toolCallId);
184
+ const output = event.success ? normalizeJsonValue(event.output, this.core.toolOutputLimits) : undefined;
185
+ const projectedError = event.success
186
+ ? undefined
187
+ : this.core.projectExternalError(run, trustedExecutionError ?? event.error, 'toolCallback');
188
+ const bytes = event.success ? jsonBytes(output) : Buffer.byteLength(projectedError!.message);
189
+ if (!this.reserveCallbackCapacity(run, 1, bytes - (part.output === undefined ? 0 : jsonBytes(part.output)), 0)) return;
190
+ if (event.success) {
191
+ part.status = 'completed';
192
+ part.output = output!;
193
+ } else {
194
+ part.status = 'failed';
195
+ delete part.output;
196
+ part.error = projectedError!.message;
197
+ }
198
+ const original = run.deduplicatedToolCalls.get(event.toolCallId);
199
+ if (original !== undefined) {
200
+ part.deduplicatedFrom = this.core.boundedIdentifier(original, 'deduplicatedFrom');
201
+ }
202
+ part.completedAt = new Date().toISOString();
203
+ this.core.emitPartEvent(run, 'part.completed', part);
204
+ } catch (error) {
205
+ this.failCallback(run, this.core.projectExternalError(run, error, 'toolCallback'));
206
+ }
207
+ }
208
+
209
+ public onToolUpdate(
210
+ state: IStorageState,
211
+ sessionId: string,
212
+ event: TFlexAgentToolCallUpdateEvent,
213
+ ): void {
214
+ let run = this.callbackRun(state, sessionId);
215
+ if (!run) return;
216
+ if (!run.toolPartIds.has(event.toolCallId)) {
217
+ this.onToolStart(state, sessionId, event);
218
+ run = this.callbackRun(state, sessionId);
219
+ if (!run) return;
220
+ }
221
+ const partId = run.toolPartIds.get(event.toolCallId);
222
+ const part = run.callbackParts.find((entry) => entry.partId === partId && entry.type === 'tool');
223
+ if (!part || part.type !== 'tool' || part.status !== 'running') return;
224
+ try {
225
+ const output = normalizeJsonValue(event.output, this.core.toolOutputLimits);
226
+ if (!this.reserveCallbackCapacity(run, 1, jsonBytes(output) - (part.output === undefined ? 0 : jsonBytes(part.output)), 0)) return;
227
+ part.output = output;
228
+ this.core.emitPartEvent(run, 'part.updated', part);
229
+ } catch (error) {
230
+ this.failCallback(run, this.core.projectExternalError(run, error, 'toolCallback'));
231
+ }
232
+ }
233
+
234
+ public onRetry(state: IStorageState, sessionId: string, retry: plugins.IAgentRetryEvent): void {
235
+ const run = this.callbackRun(state, sessionId);
236
+ if (!run || !this.reserveCallbackCapacity(run, 1, 0, 0)) return;
237
+ this.core.emitEvent(run.scopeId, run.stored.session, {
238
+ type: 'run.retrying',
239
+ runId: run.runId,
240
+ attempt: retry.attempt,
241
+ maxAttempts: retry.maxAttempts,
242
+ delayMs: retry.delayMs,
243
+ reason: retry.reason,
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Emits the usage of every model call and adds reported usage to its run. A run executes a
249
+ * transactional generation, and `AgentSession.generate()` settles a transactional generation only
250
+ * after it has reported each of its model calls, also when it is aborted. `executeRun()` awaits
251
+ * that settlement before it builds the terminal, so the run is still active for each of its
252
+ * events: they all reach `run.usage` and precede its `run.finished`. The events bypass the
253
+ * callback limits and a failed callback: their number is bounded by the run's model calls, and
254
+ * the usage of a run that exhausted its callback budget or failed in a callback must not be lost.
255
+ */
256
+ public onModelCallUsage(
257
+ state: IStorageState,
258
+ stored: IStoredSessionState,
259
+ event: plugins.TAgentModelCallUsageEvent,
260
+ ): void {
261
+ try {
262
+ const { source, generationId, ...call } = event;
263
+ const sessionId = stored.session.sessionId;
264
+ const activeRun = state.activeRuns.get(sessionId);
265
+ const run = generationId !== undefined && activeRun?.runId === generationId ? activeRun : undefined;
266
+ const operation = source === 'compaction' ? stored.metricsCompaction : run?.metricsOperation;
267
+ if (!operation) throw new Error('Model call usage has no owning metrics operation.');
268
+ observeSessionMetricsCall(operation, call);
269
+ let scopeId = run?.scopeId;
270
+ if (scopeId === undefined) {
271
+ const ambientContext = this.core.compaction.compactorInvocationContext.getStore();
272
+ const invocationContext = ambientContext
273
+ ? this.core.compaction.deferredCompactorContexts.get(ambientContext) ?? ambientContext
274
+ : undefined;
275
+ if (
276
+ !invocationContext
277
+ || invocationContext.storageKey !== state.storageKey
278
+ || invocationContext.sessionId !== sessionId
279
+ ) {
280
+ throw new Error('Model call usage is missing its exact FlexHarness invocation context.');
281
+ }
282
+ scopeId = invocationContext.scopeId;
283
+ }
284
+ if (run && call.status === 'reported') {
285
+ for (const key of Object.keys(run.usage) as (keyof IFlexUsage)[]) run.usage[key] += call.usage[key];
286
+ }
287
+ this.core.emitEvent(scopeId, stored.session, {
288
+ type: 'model.usage',
289
+ ...(generationId === undefined ? {} : { runId: generationId }),
290
+ source,
291
+ ...call,
292
+ });
293
+ } catch (error) {
294
+ // AgentSession isolates the entire callback, including event/context failures. Join it later.
295
+ stored.metricsFailure ??= error;
296
+ throw error;
297
+ }
298
+ }
299
+
300
+ public async settleMetricsOperation(
301
+ state: IStorageState,
302
+ stored: IStoredSessionState,
303
+ operation: ISessionMetricsOperation,
304
+ ): Promise<void> {
305
+ try {
306
+ if (stored.metricsFailure !== undefined) throw stored.metricsFailure;
307
+ await this.core.mutateProjection(state, stored, () => settleSessionMetricsOperation(stored.metrics, operation), true);
308
+ } catch (error) {
309
+ stored.metricsFailure ??= error;
310
+ // Never allow a later operation to overwrite a marker whose spend was not durably joined.
311
+ state.lifecycle = 'fenced';
312
+ throw error;
313
+ }
314
+ }
315
+
316
+ private callbackRun(state: IStorageState, sessionId: string): IActiveRun | undefined {
317
+ const run = state.activeRuns.get(sessionId);
318
+ return !run || run.callbacksClosed || run.callbackError ? undefined : run;
319
+ }
320
+
321
+ public reserveCallbackCapacity(
322
+ run: IActiveRun,
323
+ eventCount: number,
324
+ outputBytes: number,
325
+ partCount: number,
326
+ ): boolean {
327
+ const nextEvents = run.callbackEventCount + eventCount;
328
+ const nextOutputBytes = run.callbackOutputBytes + outputBytes;
329
+ const nextParts = run.callbackParts.length + partCount;
330
+ if (
331
+ !Number.isSafeInteger(nextEvents)
332
+ || !Number.isSafeInteger(nextOutputBytes)
333
+ || !Number.isSafeInteger(nextParts)
334
+ || nextEvents > this.core.callbackLimits.maxEvents
335
+ || nextOutputBytes > this.core.callbackLimits.maxOutputBytes
336
+ || nextParts > this.core.callbackLimits.maxParts
337
+ ) {
338
+ this.failCallback(run, this.core.trustInternalError(new FlexHarnessCallbackOverflowError(
339
+ `Callback buffer exceeded its limit (${nextEvents} events, ${nextOutputBytes} bytes, ${nextParts} parts).`,
340
+ )));
341
+ return false;
342
+ }
343
+ run.callbackEventCount = nextEvents;
344
+ run.callbackOutputBytes = nextOutputBytes;
345
+ return true;
346
+ }
347
+
348
+ private failCallback(run: IActiveRun, error: Error): void {
349
+ run.callbackError ??= error;
350
+ this.core.runs.abortRunInternally(run, error);
351
+ }
352
+ }
@@ -0,0 +1,167 @@
1
+ import * as plugins from './plugins.js';
2
+ import {
3
+ FlexHarnessAbortError,
4
+ FlexHarnessSlashCommandReentryError,
5
+ FlexHarnessSlashCommandUnavailableError,
6
+ } from './errors.js';
7
+ import type {
8
+ IFlexAgentContextCompactionOptions,
9
+ IFlexAgentContextInvocation,
10
+ IFlexCompactSessionOptions,
11
+ } from './interfaces.js';
12
+ import { validateCompactSessionOptions } from './utils.validation.js';
13
+ import type {
14
+ ICompactorInvocation,
15
+ IStoredSessionState,
16
+ IStorageState,
17
+ IActiveRun,
18
+ } from './interfaces.flexharness.js';
19
+ import type { FlexHarnessCore } from './classes.flexharness.core.js';
20
+
21
+ /** Compaction hooks: compactor contexts and session compaction. */
22
+ export class FlexHarnessCompaction<TScope> {
23
+ public readonly compactorInvocationContext = new plugins.AsyncLocalStorage<
24
+ ICompactorInvocation<TScope>
25
+ >();
26
+ public readonly deferredCompactorContexts = new WeakMap<
27
+ IFlexAgentContextInvocation<unknown>,
28
+ IFlexAgentContextInvocation<unknown>
29
+ >();
30
+
31
+ constructor(private readonly core: FlexHarnessCore<TScope>) {}
32
+
33
+ public async compactSession(
34
+ scopeId: string,
35
+ sessionId: string,
36
+ options: IFlexCompactSessionOptions = {},
37
+ ): Promise<void> {
38
+ const { modelHint } = validateCompactSessionOptions(options);
39
+ const { scope, state } = await this.core.resolveState(scopeId);
40
+ const stored = this.core.requireSession(state, sessionId);
41
+ this.core.assertSessionWritable(stored);
42
+ const invocationOwner = this.core.commands.slashCommandInvocationContext.getStore();
43
+ if (invocationOwner?.sessionKey === this.core.commands.slashCommandSessionKey(state.storageKey, sessionId)) {
44
+ throw this.core.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
45
+ }
46
+ this.core.assertStateAcceptingWork(state);
47
+ if (!this.core.agentSessionPolicy.contextCompactor) {
48
+ throw new FlexHarnessSlashCommandUnavailableError(
49
+ 'compact',
50
+ 'No context compactor is configured.',
51
+ );
52
+ }
53
+ this.core.commands.requireSessionAvailableForSlashCommand(state, stored, 'compact');
54
+ await this.core.commands.trackSlashCommandExecution(
55
+ scopeId,
56
+ state,
57
+ sessionId,
58
+ 'operation',
59
+ undefined,
60
+ async (signal) => {
61
+ await this.core.reversions.commitRevertedBranch(state, stored, scopeId, scope.scope);
62
+ await this.compactStoredSession(scopeId, scope.scope, state, stored, modelHint, signal);
63
+ },
64
+ );
65
+ }
66
+
67
+ public async compactStoredSession(
68
+ scopeId: string,
69
+ scope: TScope,
70
+ state: IStorageState,
71
+ stored: IStoredSessionState,
72
+ modelHint: string | undefined,
73
+ signal?: AbortSignal,
74
+ ): Promise<void> {
75
+ try {
76
+ await this.withCompactorContext(
77
+ this.createCompactorContext(
78
+ scopeId,
79
+ scope,
80
+ state.storageKey,
81
+ stored.session.sessionId,
82
+ modelHint,
83
+ ),
84
+ () => stored.agentSession.compact(signal === undefined ? {} : { abort: signal }),
85
+ );
86
+ } catch (error) {
87
+ throw this.core.projectOperationError(
88
+ error,
89
+ 'agentSession',
90
+ scopeId,
91
+ stored.session.sessionId,
92
+ 'compaction',
93
+ );
94
+ }
95
+ }
96
+
97
+ public abortCompactorLifecycle(stored: IStoredSessionState, reason: FlexHarnessAbortError): void {
98
+ if (!stored.compactorLifecycleController.signal.aborted) {
99
+ stored.compactorLifecycleController.abort(reason);
100
+ }
101
+ }
102
+
103
+ public createCompactorContext(
104
+ scopeId: string,
105
+ scope: TScope,
106
+ storageKey: string,
107
+ sessionId: string,
108
+ modelHint?: string,
109
+ ): ICompactorInvocation<TScope> {
110
+ return Object.freeze({
111
+ scopeId,
112
+ scope,
113
+ storageKey,
114
+ sessionId,
115
+ ...(modelHint === undefined ? {} : { modelHint }),
116
+ });
117
+ }
118
+
119
+ /**
120
+ * The model the compacted work was dispatched on: the resolved model of the run whose context
121
+ * overflowed, or the model hint a manual compaction was requested with. Retention states none.
122
+ */
123
+ public compactionModel(
124
+ state: IStorageState,
125
+ sessionId: string,
126
+ ambientContext: IFlexAgentContextInvocation<TScope>,
127
+ reason: IFlexAgentContextCompactionOptions['reason'],
128
+ modelHint: string | undefined,
129
+ ): Pick<IFlexAgentContextCompactionOptions<TScope>, 'resolvedModel' | 'modelHint'> {
130
+ if (reason === 'manual') return modelHint === undefined ? {} : { modelHint };
131
+ if (reason !== 'context-overflow' && reason !== 'context-budget') return {};
132
+ const run = state.activeRuns.get(sessionId);
133
+ if (
134
+ !run
135
+ || (ambientContext !== run.originCompactorContext && ambientContext !== run.deferredCompactorContext)
136
+ ) {
137
+ throw new Error('Context-overflow compaction is missing its exact FlexHarness run.');
138
+ }
139
+ const resolved = run.modelResolution;
140
+ if (!resolved) throw new Error('Context-overflow compaction ran before its run resolved a model.');
141
+ return {
142
+ resolvedModel: Object.freeze({ ...resolved, identity: Object.freeze({ ...resolved.identity }) }),
143
+ };
144
+ }
145
+
146
+ public withCompactorContext<TResult>(
147
+ context: IFlexAgentContextInvocation<unknown>,
148
+ operation: () => TResult,
149
+ ): TResult {
150
+ return this.compactorInvocationContext.run(
151
+ context as IFlexAgentContextInvocation<TScope>,
152
+ operation,
153
+ );
154
+ }
155
+
156
+ public createRunCompactorContext(run: IActiveRun): IFlexAgentContextInvocation<TScope> {
157
+ return run.originCompactorContext as IFlexAgentContextInvocation<TScope>;
158
+ }
159
+
160
+ public effectiveRunCompactorContext(run: IActiveRun): IFlexAgentContextInvocation<TScope> {
161
+ return (run.deferredCompactorContext ?? run.originCompactorContext) as IFlexAgentContextInvocation<TScope>;
162
+ }
163
+
164
+ public withRunCompactorContext<TResult>(run: IActiveRun, operation: () => TResult): TResult {
165
+ return this.withCompactorContext(this.effectiveRunCompactorContext(run), operation);
166
+ }
167
+ }
@@ -0,0 +1,167 @@
1
+ import { FlexHarnessAbortError, FlexHarnessValidationError } from './errors.js';
2
+ import {
3
+ normalizeAllowedTools,
4
+ normalizeSlashCommands,
5
+ normalizeSubagents,
6
+ } from './utils.harnessoptions.js';
7
+ import type {
8
+ IStoredSessionState,
9
+ IStorageState,
10
+ IResolvedSessionConfiguration,
11
+ ISessionConfigurationOwner,
12
+ } from './interfaces.flexharness.js';
13
+ import { requireSessionGeneration } from './utils.flexharness.js';
14
+ import type { FlexHarnessCore } from './classes.flexharness.core.js';
15
+
16
+ /** Session configurations from the session configuration resolver, and their owners. */
17
+ export class FlexHarnessSessionConfigurations<TScope> {
18
+ public readonly sessionConfigurations = new Map<string, ISessionConfigurationOwner<TScope>>();
19
+
20
+ constructor(private readonly core: FlexHarnessCore<TScope>) {}
21
+
22
+ public async resolveSessionConfiguration(
23
+ scopeId: string,
24
+ scope: TScope,
25
+ state: IStorageState,
26
+ stored: IStoredSessionState,
27
+ signal?: AbortSignal,
28
+ ): Promise<IResolvedSessionConfiguration<TScope>> {
29
+ const configurationRef = stored.session.configurationRef;
30
+ if (configurationRef === undefined) {
31
+ return { subagents: this.core.subagents, slashCommands: this.core.slashCommands };
32
+ }
33
+ const sessionId = stored.session.sessionId;
34
+ const generation = requireSessionGeneration(stored.session);
35
+ const key = JSON.stringify([
36
+ state.storageKey,
37
+ sessionId,
38
+ generation.sessionGenerationId,
39
+ generation.sessionGenerationSequence,
40
+ configurationRef,
41
+ ]);
42
+ let owner = this.sessionConfigurations.get(key);
43
+ if (!owner) {
44
+ const controller = new AbortController();
45
+ const parent = stored.session.parentSessionId === undefined
46
+ ? undefined
47
+ : state.sessions.get(stored.session.parentSessionId);
48
+ if (stored.session.parentSessionId !== undefined && !parent) {
49
+ throw this.core.trustInternalError(new FlexHarnessAbortError('The session parent is no longer available.'));
50
+ }
51
+ const parentGeneration = parent === undefined ? undefined : requireSessionGeneration(parent.session);
52
+ const context = Object.freeze({
53
+ scopeId,
54
+ scope,
55
+ storageKey: state.storageKey,
56
+ sessionId,
57
+ ...generation,
58
+ configurationRef,
59
+ ...(parent === undefined ? {} : {
60
+ parentSessionId: parent.session.sessionId,
61
+ parentSessionGenerationId: parentGeneration!.sessionGenerationId,
62
+ parentSessionGenerationSequence: parentGeneration!.sessionGenerationSequence,
63
+ }),
64
+ ...(stored.session.agent === undefined ? {} : { agent: stored.session.agent }),
65
+ depth: stored.session.depth ?? 0,
66
+ signal: controller.signal,
67
+ });
68
+ let rejectAbort!: (reason: unknown) => void;
69
+ const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject; });
70
+ const onAbort = () => rejectAbort(controller.signal.reason);
71
+ controller.signal.addEventListener('abort', onAbort, { once: true });
72
+ const resolved = Promise.resolve().then(() => {
73
+ if (!this.core.sessionConfigurationResolver) {
74
+ throw new Error('A sessionConfigurationResolver is required for bound sessions.');
75
+ }
76
+ return this.core.sessionConfigurationResolver.resolveSessionConfiguration(context);
77
+ });
78
+ const completion = Promise.race([resolved, aborted]).then((configuration) => {
79
+ controller.signal.throwIfAborted();
80
+ if (
81
+ state.lifecycle !== 'active'
82
+ || state.sessions.get(sessionId) !== stored
83
+ || stored.session.configurationRef !== configurationRef
84
+ || stored.session.sessionGenerationId !== generation.sessionGenerationId
85
+ || stored.session.sessionGenerationSequence !== generation.sessionGenerationSequence
86
+ ) {
87
+ throw this.core.trustInternalError(new FlexHarnessAbortError(
88
+ 'The session configuration lost its session generation ownership.',
89
+ ));
90
+ }
91
+ if (
92
+ !configuration
93
+ || typeof configuration !== 'object'
94
+ || Array.isArray(configuration)
95
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(configuration))
96
+ || Reflect.ownKeys(configuration).some((name) =>
97
+ typeof name !== 'string' || !['subagents', 'slashCommands', 'allowedTools', 'permissionPolicy'].includes(name))
98
+ ) throw new FlexHarnessValidationError('Resolved session configuration is invalid.');
99
+ if (configuration.permissionPolicy !== undefined && typeof configuration.permissionPolicy !== 'function') {
100
+ throw new FlexHarnessValidationError('Resolved session permissionPolicy must be a function.');
101
+ }
102
+ return Object.freeze({
103
+ subagents: normalizeSubagents(configuration.subagents),
104
+ slashCommands: normalizeSlashCommands(configuration.slashCommands),
105
+ allowedTools: normalizeAllowedTools(configuration.allowedTools),
106
+ permissionPolicy: configuration.permissionPolicy,
107
+ });
108
+ }).catch((error: unknown) => {
109
+ if (this.sessionConfigurations.get(key)?.completion === completion) {
110
+ this.sessionConfigurations.delete(key);
111
+ }
112
+ if (controller.signal.aborted) throw controller.signal.reason;
113
+ throw this.core.projectOperationError(
114
+ error,
115
+ 'sessionConfigurationResolver',
116
+ scopeId,
117
+ sessionId,
118
+ 'session-configuration',
119
+ );
120
+ }).finally(() => {
121
+ controller.signal.removeEventListener('abort', onAbort);
122
+ const current = this.sessionConfigurations.get(key);
123
+ if (current?.completion === completion) current.settled = true;
124
+ });
125
+ owner = { storageKey: state.storageKey, sessionId, controller, completion, settled: false, waiters: 0 };
126
+ this.sessionConfigurations.set(key, owner);
127
+ }
128
+ owner.waiters++;
129
+ let onConsumerAbort: (() => void) | undefined;
130
+ const consumerAbort = signal && new Promise<never>((_resolve, reject) => {
131
+ onConsumerAbort = () => reject(signal.reason ?? this.core.trustInternalError(
132
+ new FlexHarnessAbortError('Session configuration resolution was aborted.'),
133
+ ));
134
+ signal.addEventListener('abort', onConsumerAbort, { once: true });
135
+ if (signal.aborted) onConsumerAbort();
136
+ });
137
+ try {
138
+ return await (consumerAbort
139
+ ? Promise.race([owner.completion, consumerAbort])
140
+ : owner.completion);
141
+ } finally {
142
+ if (signal && onConsumerAbort) signal.removeEventListener('abort', onConsumerAbort);
143
+ owner.waiters--;
144
+ if (owner.waiters === 0 && !owner.settled && !owner.controller.signal.aborted) {
145
+ if (this.sessionConfigurations.get(key) === owner) this.sessionConfigurations.delete(key);
146
+ owner.controller.abort(this.core.trustInternalError(new FlexHarnessAbortError(
147
+ 'Session configuration resolution has no active callers.',
148
+ )));
149
+ }
150
+ }
151
+ }
152
+
153
+ public abortSessionConfigurations(
154
+ storageKey: string,
155
+ reason: FlexHarnessAbortError,
156
+ sessionIds?: ReadonlySet<string>,
157
+ ): Promise<void> | undefined {
158
+ const owners = [...this.sessionConfigurations.entries()].filter(([, owner]) =>
159
+ owner.storageKey === storageKey && (sessionIds === undefined || sessionIds.has(owner.sessionId)));
160
+ if (owners.length === 0) return undefined;
161
+ for (const [key, owner] of owners) {
162
+ this.sessionConfigurations.delete(key);
163
+ if (!owner.controller.signal.aborted) owner.controller.abort(reason);
164
+ }
165
+ return Promise.allSettled(owners.map(([, owner]) => owner.completion)).then(() => undefined);
166
+ }
167
+ }