@modelprofile.com/flexharness 13.1.0 → 13.2.0

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 (53) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.flexharness.cachewarm.d.ts +64 -0
  3. package/dist_ts/classes.flexharness.cachewarm.js +353 -0
  4. package/dist_ts/classes.flexharness.callbacks.js +2 -1
  5. package/dist_ts/classes.flexharness.core.d.ts +4 -1
  6. package/dist_ts/classes.flexharness.core.js +9 -3
  7. package/dist_ts/classes.flexharness.d.ts +10 -1
  8. package/dist_ts/classes.flexharness.deletion.js +2 -1
  9. package/dist_ts/classes.flexharness.js +14 -1
  10. package/dist_ts/classes.flexharness.lifecycle.js +4 -1
  11. package/dist_ts/classes.flexharness.prompts.d.ts +1 -1
  12. package/dist_ts/classes.flexharness.prompts.js +28 -9
  13. package/dist_ts/classes.flexharness.reversions.d.ts +2 -0
  14. package/dist_ts/classes.flexharness.reversions.js +7 -2
  15. package/dist_ts/classes.flexharness.runs.js +25 -3
  16. package/dist_ts/classes.toolcatalog.d.ts +10 -2
  17. package/dist_ts/classes.toolcatalog.js +73 -7
  18. package/dist_ts/interfaces.d.ts +89 -1
  19. package/dist_ts/interfaces.flexharness.d.ts +15 -0
  20. package/dist_ts/interfaces.js +1 -1
  21. package/dist_ts/plugins.d.ts +1 -1
  22. package/dist_ts/plugins.js +1 -1
  23. package/dist_ts/utils.harnessoptions.d.ts +8 -1
  24. package/dist_ts/utils.harnessoptions.js +50 -6
  25. package/dist_ts/utils.json.js +9 -2
  26. package/dist_ts/utils.sessionpreview.d.ts +3 -3
  27. package/dist_ts/utils.sessionpreview.js +5 -5
  28. package/dist_ts_chat_harness/classes.transcript.js +17 -4
  29. package/dist_ts_chat_harness/functions.mapping.d.ts +1 -0
  30. package/dist_ts_chat_harness/functions.mapping.js +5 -2
  31. package/package.json +3 -3
  32. package/readme.md +27 -0
  33. package/ts/00_commitinfo_data.ts +1 -1
  34. package/ts/classes.flexharness.cachewarm.ts +433 -0
  35. package/ts/classes.flexharness.callbacks.ts +1 -0
  36. package/ts/classes.flexharness.core.ts +8 -1
  37. package/ts/classes.flexharness.deletion.ts +1 -0
  38. package/ts/classes.flexharness.lifecycle.ts +3 -0
  39. package/ts/classes.flexharness.prompts.ts +25 -6
  40. package/ts/classes.flexharness.reversions.ts +7 -1
  41. package/ts/classes.flexharness.runs.ts +20 -2
  42. package/ts/classes.flexharness.ts +21 -0
  43. package/ts/classes.toolcatalog.ts +76 -7
  44. package/ts/interfaces.flexharness.ts +16 -0
  45. package/ts/interfaces.ts +100 -1
  46. package/ts/plugins.ts +1 -0
  47. package/ts/readme.md +27 -0
  48. package/ts/utils.harnessoptions.ts +74 -5
  49. package/ts/utils.json.ts +8 -1
  50. package/ts/utils.sessionpreview.ts +4 -4
  51. package/ts_chat_harness/classes.transcript.ts +14 -3
  52. package/ts_chat_harness/functions.mapping.ts +3 -1
  53. package/ts_chat_harness/readme.md +1 -0
@@ -0,0 +1,433 @@
1
+ import * as plugins from './plugins.js';
2
+ import {
3
+ FlexHarnessNotFoundError,
4
+ FlexHarnessSessionBusyError,
5
+ FlexHarnessValidationError,
6
+ errorToInfo,
7
+ } from './errors.js';
8
+ import type {
9
+ IFlexCacheWarmInput,
10
+ IFlexCacheWarmState,
11
+ IFlexPromptResult,
12
+ IFlexSession,
13
+ } from './interfaces.js';
14
+ import type {
15
+ IActiveRun,
16
+ IQueuedPrompt,
17
+ IStoredSessionState,
18
+ IStorageState,
19
+ TEventDetails,
20
+ } from './interfaces.flexharness.js';
21
+ import type { INormalizedCacheWarmOptions } from './utils.harnessoptions.js';
22
+ import { validateIdentifier } from './utils.validation.js';
23
+ import type { FlexHarnessCore } from './classes.flexharness.core.js';
24
+
25
+ /** The prompt of a cache-warm turn. It only has the model read the cached prefix again. */
26
+ export const FLEX_CACHE_WARM_PROMPT =
27
+ '[cache-warm] This message only keeps the prompt cache warm. No action is needed: do not call '
28
+ + 'any tool and reply with the single word ok.';
29
+
30
+ /** The cache TTL assumed when the model call does not report its own. */
31
+ const defaultCacheTtlMs = 5 * 60 * 1000;
32
+ /** How long before the cache expires a warm turn reads it. */
33
+ const cacheWarmMarginMs = 60 * 1000;
34
+ // A stray tool call is refused in the first step and answered in the second.
35
+ const cacheWarmMaxSteps = 2;
36
+
37
+ interface ISessionCacheWarm {
38
+ key: string;
39
+ scopeId: string;
40
+ sessionId: string;
41
+ /**
42
+ * The loaded scope state and session the keeper warms. The keeper never loads a scope: once
43
+ * these are retired, deleted or replaced, it stops.
44
+ */
45
+ state: IStorageState;
46
+ stored: IStoredSessionState;
47
+ /** The keeper's stop time, on its clock. */
48
+ warmUntil: number;
49
+ /** When the keeper was set, on its clock: the idle baseline before any model call. */
50
+ setAt: number;
51
+ deadlineTimer?: unknown;
52
+ warmTimer?: unknown;
53
+ nextWarmAt?: number;
54
+ firing: boolean;
55
+ /** The session's idle time since its previous model call when the last warm turn was due. */
56
+ firedAfterIdleMs?: number;
57
+ }
58
+
59
+ /** The idle time after a model call at which a warm turn reads the cache, from its TTL. */
60
+ export function cacheWarmIdleMs(ttlMs: number | undefined): number {
61
+ const ttl = ttlMs ?? defaultCacheTtlMs;
62
+ return Math.max(ttl - cacheWarmMarginMs, Math.floor(ttl / 2));
63
+ }
64
+
65
+ /** Replaces each tool's `execute` so a cache-warm turn refuses any tool call it makes. */
66
+ export function refuseToolExecution(tools: Record<string, unknown>): void {
67
+ for (const [name, tool] of Object.entries(tools)) {
68
+ if (!tool || typeof tool !== 'object' || typeof (tool as { execute?: unknown }).execute !== 'function') continue;
69
+ tools[name] = {
70
+ ...tool,
71
+ execute: async () => {
72
+ throw new FlexHarnessValidationError('A cache-warm turn runs no tools.');
73
+ },
74
+ };
75
+ }
76
+ }
77
+
78
+ /** Parses the agent tool's duration: `off`, `0`, or an amount of `s`, `m` or `h`, such as `1h`. */
79
+ export function parseCacheWarmDuration(duration: string): number {
80
+ const normalized = duration.trim().toLowerCase();
81
+ if (normalized === 'off' || normalized === '0') return 0;
82
+ const match = /^(\d{1,6})\s*(s|m|h)$/u.exec(normalized);
83
+ if (!match) {
84
+ throw new FlexHarnessValidationError('duration must be "off", "0" or an amount of s, m or h, such as "1h".');
85
+ }
86
+ const unitMs = match[2] === 's' ? 1000 : match[2] === 'm' ? 60_000 : 3_600_000;
87
+ return Number(match[1]) * unitMs;
88
+ }
89
+
90
+ /**
91
+ * Keeps a session's prompt cache warm until a deadline. Whenever the session has been idle for
92
+ * the cache TTL less a minute, it sends a marked cache-warm prompt, whose short turn reads the
93
+ * cached prefix again and so refreshes it. Process-local: it ends with the harness.
94
+ */
95
+ export class FlexHarnessCacheWarm<TScope> {
96
+ private readonly entries = new Map<string, ISessionCacheWarm>();
97
+
98
+ constructor(private readonly core: FlexHarnessCore<TScope>) {}
99
+
100
+ private get options(): INormalizedCacheWarmOptions {
101
+ return this.core.cacheWarmOptions;
102
+ }
103
+
104
+ private key(storageKey: string, sessionId: string): string {
105
+ return `${storageKey}\u0000${sessionId}`;
106
+ }
107
+
108
+ /** Sets the session's warm deadline to `durationMs` from now, capped; 0 stops the keeper. */
109
+ public async keepCacheWarm(
110
+ scopeId: string,
111
+ sessionId: string,
112
+ input: IFlexCacheWarmInput,
113
+ ): Promise<IFlexCacheWarmState> {
114
+ this.core.assertOpen();
115
+ validateIdentifier(sessionId, 'sessionId');
116
+ if (!input || typeof input !== 'object' || !Number.isSafeInteger(input.durationMs) || input.durationMs < 0) {
117
+ throw new FlexHarnessValidationError('durationMs must be a non-negative integer.');
118
+ }
119
+ const { state } = await this.core.resolveState(scopeId);
120
+ this.core.assertStateAcceptingWork(state);
121
+ const stored = this.core.requireSession(state, sessionId);
122
+ return this.set(scopeId, state, stored, input.durationMs, 'host');
123
+ }
124
+
125
+ public async getCacheWarm(scopeId: string, sessionId: string): Promise<IFlexCacheWarmState> {
126
+ validateIdentifier(sessionId, 'sessionId');
127
+ const { state } = await this.core.resolveState(scopeId);
128
+ const stored = this.core.requireSession(state, sessionId);
129
+ return this.project(stored, this.entries.get(this.key(state.storageKey, sessionId)));
130
+ }
131
+
132
+ /** The shared setter of the host API and the agent tool. */
133
+ public set(
134
+ scopeId: string,
135
+ state: IStorageState,
136
+ stored: IStoredSessionState,
137
+ durationMs: number,
138
+ source: 'host' | 'agent',
139
+ ): IFlexCacheWarmState {
140
+ this.core.assertSessionWritable(stored);
141
+ if (stored.session.agent !== undefined) {
142
+ throw new FlexHarnessValidationError('Only a root session keeps its cache warm.');
143
+ }
144
+ const key = this.key(state.storageKey, stored.session.sessionId);
145
+ const existing = this.entries.get(key);
146
+ if (durationMs === 0) {
147
+ if (existing) {
148
+ this.remove(existing);
149
+ this.emit(scopeId, stored.session, { type: 'cache.warm.changed', source });
150
+ this.emit(scopeId, stored.session, { type: 'cache.warm.stopped', reason: 'cleared' });
151
+ }
152
+ return this.project(stored, undefined);
153
+ }
154
+ const now = this.options.clock.now();
155
+ const entry: ISessionCacheWarm = existing ?? {
156
+ key,
157
+ scopeId,
158
+ sessionId: stored.session.sessionId,
159
+ state,
160
+ stored,
161
+ warmUntil: now,
162
+ setAt: now,
163
+ firing: false,
164
+ };
165
+ entry.scopeId = scopeId;
166
+ entry.state = state;
167
+ entry.stored = stored;
168
+ entry.warmUntil = now + Math.min(durationMs, this.options.maxDurationMs);
169
+ this.entries.set(key, entry);
170
+ if (entry.deadlineTimer !== undefined) this.options.clock.clearTimeout(entry.deadlineTimer);
171
+ entry.deadlineTimer = this.options.clock.setTimeout(
172
+ () => this.stopAtDeadline(entry),
173
+ entry.warmUntil - now,
174
+ );
175
+ this.emit(scopeId, stored.session, {
176
+ type: 'cache.warm.changed',
177
+ source,
178
+ warmUntil: new Date(this.toWallTime(entry.warmUntil)).toISOString(),
179
+ });
180
+ if (!entry.firing) this.arm(entry, state, stored);
181
+ return this.project(stored, entry);
182
+ }
183
+
184
+ /** The `keep_cache_warm` built-in tool of a root session. */
185
+ public createKeepCacheWarmTool(run: IActiveRun): unknown {
186
+ const maxHours = this.options.maxDurationMs / 3_600_000;
187
+ return plugins.tool({
188
+ description: 'Keep this session\'s prompt cache warm while you expect further work, so the next turn '
189
+ + 'reads the cached context instead of writing it again. While the session is idle, the harness then '
190
+ + 'sends a short "[cache-warm]" message shortly before the cache would expire. A duration such as '
191
+ + `"30m" or "1h" sets the deadline to that long from now, at most ${maxHours}h; calling the tool again `
192
+ + 'replaces the deadline; "off" or "0" stops it.',
193
+ inputSchema: plugins.z.object({
194
+ duration: plugins.z.string().min(1).max(32),
195
+ }).strict(),
196
+ execute: async ({ duration }: { duration: string }) => {
197
+ const durationMs = parseCacheWarmDuration(duration);
198
+ this.core.assertStateAcceptingWork(run.state);
199
+ if (run.state.sessions.get(run.sessionId) !== run.stored) {
200
+ throw new FlexHarnessNotFoundError('Session', run.sessionId);
201
+ }
202
+ return this.set(run.scopeId, run.state, run.stored, durationMs, 'agent');
203
+ },
204
+ });
205
+ }
206
+
207
+ /** A prompt or steer arrived: the session is active, so no warm turn is due. */
208
+ public onActivity(storageKey: string, sessionId: string): void {
209
+ const entry = this.entries.get(this.key(storageKey, sessionId));
210
+ if (entry) this.disarm(entry);
211
+ }
212
+
213
+ /** A root run starts: it is activity, and its model options are what warm turns repeat. */
214
+ public onRunStarted(run: IActiveRun, queued: IQueuedPrompt): void {
215
+ if (run.stored.session.agent !== undefined) return;
216
+ this.onActivity(run.state.storageKey, run.sessionId);
217
+ if (run.cacheWarm) return;
218
+ const options = queued.options ?? {};
219
+ run.stored.cacheWarmPromptOptions = {
220
+ ...(options.modelHint === undefined ? {} : { modelHint: options.modelHint }),
221
+ ...(options.system === undefined ? {} : { system: options.system }),
222
+ };
223
+ }
224
+
225
+ /** A model call of the session was dispatched: the cache's TTL counts from now. */
226
+ public onModelDispatch(run: IActiveRun, dispatch: plugins.ISmartAiCacheDispatch): void {
227
+ run.stored.lastCacheDispatch = {
228
+ at: this.options.clock.now(),
229
+ idleMs: cacheWarmIdleMs(dispatch.warmDurationMs),
230
+ ...(dispatch.basis === undefined ? {} : { basis: dispatch.basis }),
231
+ };
232
+ }
233
+
234
+ /** A prompt of the session finished, whether it ran or not. */
235
+ public onPromptFinished(
236
+ queued: IQueuedPrompt,
237
+ status: 'completed' | 'failed' | 'cancelled',
238
+ error?: unknown,
239
+ result?: IFlexPromptResult,
240
+ ): void {
241
+ const entry = this.entries.get(this.key(queued.state.storageKey, queued.sessionId));
242
+ if (queued.cacheWarm && status === 'completed' && result) {
243
+ const basis = queued.stored.lastCacheDispatch?.basis;
244
+ this.emit(queued.scopeId, queued.stored.session, {
245
+ type: 'cache.warm.completed',
246
+ runId: result.runId,
247
+ usage: result.usage,
248
+ ...(entry?.firedAfterIdleMs === undefined ? {} : { idleForMs: entry.firedAfterIdleMs }),
249
+ ...(basis === undefined ? {} : { basis }),
250
+ });
251
+ }
252
+ if (!entry) return;
253
+ entry.firing = false;
254
+ if (queued.cacheWarm && status === 'failed') {
255
+ this.remove(entry);
256
+ this.emit(queued.scopeId, queued.stored.session, {
257
+ type: 'cache.warm.stopped',
258
+ reason: 'failed',
259
+ ...(queued.runId === undefined ? {} : { runId: queued.runId }),
260
+ ...(error === undefined ? {} : { error: errorToInfo(error) }),
261
+ });
262
+ return;
263
+ }
264
+ if (!this.holdsSession(entry)) return;
265
+ this.arm(entry, entry.state, entry.stored);
266
+ }
267
+
268
+ /** The scope is being retired: its keepers stop, and none loads the scope again. */
269
+ public onStateRetiring(state: IStorageState): void {
270
+ for (const entry of [...this.entries.values()]) {
271
+ if (entry.state === state) this.stopUnavailable(entry);
272
+ }
273
+ }
274
+
275
+ /** The sessions were deleted: their keepers stop. */
276
+ public onSessionsDeleted(state: IStorageState, sessionIds: Iterable<string>): void {
277
+ for (const sessionId of sessionIds) {
278
+ const entry = this.entries.get(this.key(state.storageKey, sessionId));
279
+ if (entry?.state === state) this.stopUnavailable(entry);
280
+ }
281
+ }
282
+
283
+ public dispose(): void {
284
+ for (const entry of [...this.entries.values()]) this.remove(entry);
285
+ }
286
+
287
+ private project(stored: IStoredSessionState, entry: ISessionCacheWarm | undefined): IFlexCacheWarmState {
288
+ return Object.freeze({
289
+ sessionId: stored.session.sessionId,
290
+ ...(entry ? { warmUntil: new Date(this.toWallTime(entry.warmUntil)).toISOString() } : {}),
291
+ ...(entry?.nextWarmAt === undefined
292
+ ? {}
293
+ : { nextWarmAt: new Date(this.toWallTime(entry.nextWarmAt)).toISOString() }),
294
+ ...(entry ? { idleMs: stored.lastCacheDispatch?.idleMs ?? cacheWarmIdleMs(undefined) } : {}),
295
+ ...(entry && stored.lastCacheDispatch?.basis !== undefined ? { basis: stored.lastCacheDispatch.basis } : {}),
296
+ });
297
+ }
298
+
299
+ /** Projects a time of the keeper's clock onto the wall clock. */
300
+ private toWallTime(clockTime: number): number {
301
+ return Date.now() + (clockTime - this.options.clock.now());
302
+ }
303
+
304
+ private isBusy(state: IStorageState, stored: IStoredSessionState): boolean {
305
+ return state.activeRuns.has(stored.session.sessionId)
306
+ || stored.outstandingPromptsById.size > 0
307
+ || ['running', 'scheduled', 'waiting_permission'].includes(stored.session.status);
308
+ }
309
+
310
+ /**
311
+ * Whether a warm prompt now would change the session beyond the warm turn: it would end an
312
+ * undo's redo history, or wait for a reversion or a slash command to settle.
313
+ */
314
+ private isHeld(state: IStorageState, stored: IStoredSessionState): boolean {
315
+ return stored.pendingReversion !== undefined
316
+ || this.core.reversions.hasHiddenReversionSegments(stored)
317
+ || this.core.commands.activeSlashCommandExecutions.has(
318
+ this.core.commands.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
319
+ );
320
+ }
321
+
322
+ /** Arms the warm timer of an idle session; a busy one arms it again when its run ends. */
323
+ private arm(entry: ISessionCacheWarm, state: IStorageState, stored: IStoredSessionState): void {
324
+ this.disarm(entry);
325
+ if (this.isBusy(state, stored)) return;
326
+ const mark = stored.lastCacheDispatch;
327
+ const base = mark?.at ?? entry.setAt;
328
+ const dueAt = base + (mark?.idleMs ?? cacheWarmIdleMs(undefined));
329
+ this.armAt(entry, dueAt);
330
+ }
331
+
332
+ private armAt(entry: ISessionCacheWarm, dueAt: number): void {
333
+ if (dueAt >= entry.warmUntil) return;
334
+ const now = this.options.clock.now();
335
+ entry.nextWarmAt = dueAt;
336
+ entry.warmTimer = this.options.clock.setTimeout(() => {
337
+ entry.warmTimer = undefined;
338
+ void this.fire(entry);
339
+ }, Math.max(0, dueAt - now));
340
+ }
341
+
342
+ private disarm(entry: ISessionCacheWarm): void {
343
+ if (entry.warmTimer !== undefined) this.options.clock.clearTimeout(entry.warmTimer);
344
+ entry.warmTimer = undefined;
345
+ delete entry.nextWarmAt;
346
+ }
347
+
348
+ private remove(entry: ISessionCacheWarm): void {
349
+ this.disarm(entry);
350
+ if (entry.deadlineTimer !== undefined) this.options.clock.clearTimeout(entry.deadlineTimer);
351
+ entry.deadlineTimer = undefined;
352
+ if (this.entries.get(entry.key) === entry) this.entries.delete(entry.key);
353
+ }
354
+
355
+ /** Whether the keeper's scope state is active and still holds its session. */
356
+ private holdsSession(entry: ISessionCacheWarm): boolean {
357
+ return entry.state.lifecycle === 'active'
358
+ && entry.state.sessions.get(entry.sessionId) === entry.stored
359
+ && !entry.state.tombstones.has(entry.sessionId);
360
+ }
361
+
362
+ private stopUnavailable(entry: ISessionCacheWarm, error?: unknown): void {
363
+ if (this.entries.get(entry.key) !== entry) return;
364
+ entry.firing = false;
365
+ this.remove(entry);
366
+ this.emit(entry.scopeId, entry.stored.session, {
367
+ type: 'cache.warm.stopped',
368
+ reason: 'unavailable',
369
+ ...(error === undefined ? {} : { error: errorToInfo(error) }),
370
+ });
371
+ }
372
+
373
+ private stopAtDeadline(entry: ISessionCacheWarm): void {
374
+ entry.deadlineTimer = undefined;
375
+ if (this.entries.get(entry.key) !== entry) return;
376
+ if (!this.holdsSession(entry)) {
377
+ this.stopUnavailable(entry);
378
+ return;
379
+ }
380
+ this.remove(entry);
381
+ this.emit(entry.scopeId, entry.stored.session, { type: 'cache.warm.stopped', reason: 'deadline' });
382
+ }
383
+
384
+ private async fire(entry: ISessionCacheWarm): Promise<void> {
385
+ delete entry.nextWarmAt;
386
+ if (this.entries.get(entry.key) !== entry || entry.firing || this.core.closed) return;
387
+ if (this.options.clock.now() >= entry.warmUntil) {
388
+ this.stopAtDeadline(entry);
389
+ return;
390
+ }
391
+ if (!this.holdsSession(entry)) {
392
+ this.stopUnavailable(entry);
393
+ return;
394
+ }
395
+ const { state, stored } = entry;
396
+ // A busy session arms the timer again when its run ends, through `onPromptFinished`.
397
+ if (this.isBusy(state, stored)) return;
398
+ const idleMs = stored.lastCacheDispatch?.idleMs ?? cacheWarmIdleMs(undefined);
399
+ if (this.isHeld(state, stored)) {
400
+ this.armAt(entry, this.options.clock.now() + idleMs);
401
+ return;
402
+ }
403
+ entry.firing = true;
404
+ entry.firedAfterIdleMs = this.options.clock.now() - (stored.lastCacheDispatch?.at ?? entry.setAt);
405
+ try {
406
+ await this.core.prompts.enqueuePromptInternal(
407
+ entry.scopeId,
408
+ entry.sessionId,
409
+ FLEX_CACHE_WARM_PROMPT,
410
+ { ...(stored.cacheWarmPromptOptions ?? {}), maxSteps: cacheWarmMaxSteps },
411
+ undefined,
412
+ undefined,
413
+ undefined,
414
+ undefined,
415
+ false,
416
+ true,
417
+ );
418
+ } catch (error) {
419
+ if (this.entries.get(entry.key) !== entry) return;
420
+ entry.firing = false;
421
+ if (error instanceof FlexHarnessSessionBusyError) {
422
+ // Work arrived meanwhile: its run end arms the timer again, as does a later idle check.
423
+ this.armAt(entry, this.options.clock.now() + idleMs);
424
+ return;
425
+ }
426
+ this.stopUnavailable(entry, error);
427
+ }
428
+ }
429
+
430
+ private emit(scopeId: string, session: IFlexSession, details: TEventDetails): void {
431
+ this.core.emitEvent(scopeId, session, details);
432
+ }
433
+ }
@@ -455,6 +455,7 @@ export class FlexHarnessRunCallbacks<TScope> {
455
455
  ...(generationId === undefined ? {} : { runId: generationId }),
456
456
  source,
457
457
  ...call,
458
+ ...(run?.cacheWarm ? { cacheWarm: true } : {}),
458
459
  });
459
460
  } catch (error) {
460
461
  // AgentSession isolates the entire callback, including event/context failures. Join it later.
@@ -57,6 +57,7 @@ import {
57
57
  normalizeAgentSessionPolicy,
58
58
  normalizeAttachmentResolver,
59
59
  normalizeBuiltInTools,
60
+ normalizeCacheWarmOptions,
60
61
  normalizeSlashCommands,
61
62
  normalizeToolCatalogOptions,
62
63
  normalizeSubagents,
@@ -68,6 +69,7 @@ import {
68
69
  resolveReversionMaintenanceTimeoutMs,
69
70
  resolveReversionLimits,
70
71
  type INormalizedBuiltInTools,
72
+ type INormalizedCacheWarmOptions,
71
73
  type TRegisteredSlashCommand,
72
74
  } from './utils.harnessoptions.js';
73
75
  import {
@@ -119,6 +121,7 @@ import { FlexHarnessSessionDeletion } from './classes.flexharness.deletion.js';
119
121
  import { FlexHarnessLifecycle } from './classes.flexharness.lifecycle.js';
120
122
  import { FlexHarnessRuns } from './classes.flexharness.runs.js';
121
123
  import { FlexHarnessSessions } from './classes.flexharness.sessions.js';
124
+ import { FlexHarnessCacheWarm } from './classes.flexharness.cachewarm.js';
122
125
 
123
126
  const defaultMaxSubagentDepth = 6;
124
127
  const maximumMaxSubagentDepth = 8;
@@ -169,6 +172,7 @@ export class FlexHarnessCore<TScope> {
169
172
  private readonly externalErrorProjector?: TFlexExternalErrorProjector;
170
173
  public readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
171
174
  public readonly builtInTools: INormalizedBuiltInTools;
175
+ public readonly cacheWarmOptions: INormalizedCacheWarmOptions;
172
176
  public readonly toolCatalogOptions: Readonly<IFlexToolCatalogOptions>;
173
177
  public readonly slashCommands: ReadonlyMap<string, TRegisteredSlashCommand<TScope>>;
174
178
  public readonly maxSubagentDepth: number;
@@ -198,6 +202,7 @@ export class FlexHarnessCore<TScope> {
198
202
  public readonly lifecycle = new FlexHarnessLifecycle<TScope>(this);
199
203
  public readonly runs = new FlexHarnessRuns<TScope>(this);
200
204
  public readonly sessions = new FlexHarnessSessions<TScope>(this);
205
+ public readonly cacheWarm = new FlexHarnessCacheWarm<TScope>(this);
201
206
 
202
207
  constructor(options: IFlexHarnessOptions<TScope>) {
203
208
  if (!options || typeof options !== 'object') {
@@ -243,6 +248,7 @@ export class FlexHarnessCore<TScope> {
243
248
  this.externalErrorProjector = options.externalErrorProjector;
244
249
  this.subagents = normalizeSubagents(options.subagents);
245
250
  this.builtInTools = normalizeBuiltInTools(options.builtInTools);
251
+ this.cacheWarmOptions = normalizeCacheWarmOptions(options.cacheWarm);
246
252
  const toolCatalogOptions = normalizeToolCatalogOptions(options.toolCatalog);
247
253
  this.toolCatalogOptions = Object.freeze({
248
254
  declarations: toolCatalogOptions.declarations,
@@ -1035,8 +1041,9 @@ export class FlexHarnessCore<TScope> {
1035
1041
  status: latestTerminal.status === 'completed' ? 'idle' : latestTerminal.status,
1036
1042
  completedAt,
1037
1043
  ...terminalActivityError(latestTerminal.assistantMessage),
1044
+ ...(latestTerminal.userMessage.cacheWarm ? { cacheWarm: true as const } : {}),
1038
1045
  };
1039
- stored.session.updatedAt = completedAt;
1046
+ if (!latestTerminal.userMessage.cacheWarm) stored.session.updatedAt = completedAt;
1040
1047
  } else if (['scheduled', 'running', 'waiting_permission'].includes(stored.session.status)) {
1041
1048
  const timestamp = new Date().toISOString();
1042
1049
  stored.session.status = 'cancelled';
@@ -299,6 +299,7 @@ export class FlexHarnessSessionDeletion<TScope> {
299
299
  reservation: IReservedSessionDeletion,
300
300
  ): Promise<void> {
301
301
  const { deletedSessions, descendantRoots } = reservation;
302
+ this.core.cacheWarm.onSessionsDeleted(state, deletedSessions.map((deleted) => deleted.sessionId));
302
303
  const reason = this.core.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
303
304
  const configurationSettlement = this.core.configurations.abortSessionConfigurations(
304
305
  state.storageKey,
@@ -57,6 +57,7 @@ export class FlexHarnessLifecycle<TScope> {
57
57
  this.core.commands.assertNoAmbientSlashCommandTeardown();
58
58
  if (this.disposePromise) return this.disposePromise;
59
59
  this.core.closed = true;
60
+ this.core.cacheWarm.dispose();
60
61
  let disposal!: Promise<void>;
61
62
  disposal = this.disposeInternal().catch((error) => {
62
63
  if (this.disposePromise === disposal) this.disposePromise = undefined;
@@ -78,6 +79,7 @@ export class FlexHarnessLifecycle<TScope> {
78
79
  }
79
80
  state.fenceInProgress = true;
80
81
  state.lifecycle = 'fenced';
82
+ this.core.cacheWarm.onStateRetiring(state);
81
83
  const reason = this.core.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.'));
82
84
  if (!state.compactorLifecycleController.signal.aborted) {
83
85
  state.compactorLifecycleController.abort(reason);
@@ -589,6 +591,7 @@ export class FlexHarnessLifecycle<TScope> {
589
591
  return;
590
592
  }
591
593
  if (state.lifecycle !== 'fenced') state.lifecycle = 'retiring';
594
+ this.core.cacheWarm.onStateRetiring(state);
592
595
  const configurationSettlement = this.core.configurations.abortSessionConfigurations(storageKey, reason);
593
596
  if (configurationSettlement) await configurationSettlement;
594
597
  for (const staged of [...state.sessionImports.values()]) {
@@ -259,8 +259,10 @@ export class FlexHarnessPromptQueue<TScope> {
259
259
  prompt: INormalizedFlexPrompt,
260
260
  byteSize: number,
261
261
  ): IFlexSteerAdmission {
262
+ // A cache-warm turn runs no tools and caps its output, so it takes in no steer.
262
263
  if (
263
- run.phase === 'closing'
264
+ run.cacheWarm
265
+ || run.phase === 'closing'
264
266
  || run.phase === 'finalizing'
265
267
  || run.phase === 'promoting'
266
268
  || run.callbacksClosed
@@ -323,6 +325,7 @@ export class FlexHarnessPromptQueue<TScope> {
323
325
  delegationSeed?: IDelegatedRunAdmissionSeed,
324
326
  admissionSignal?: AbortSignal,
325
327
  slashCommandAdmission = false,
328
+ cacheWarm = false,
326
329
  ): Promise<{
327
330
  admission: IFlexPromptQueueAdmission;
328
331
  started: Promise<IFlexPromptAdmission>;
@@ -381,6 +384,9 @@ export class FlexHarnessPromptQueue<TScope> {
381
384
  if (stored.pendingReversion) {
382
385
  throw new FlexHarnessSessionBusyError(sessionId, 'has a pending reversion operation');
383
386
  }
387
+ if (cacheWarm && (state.activeRuns.has(sessionId) || stored.outstandingPromptsById.size > 0)) {
388
+ throw new FlexHarnessSessionBusyError(sessionId, 'is active, so it needs no cache-warm turn');
389
+ }
384
390
  if (stored.session.agent !== undefined && delegationSeed === undefined) {
385
391
  throw new FlexHarnessValidationError(
386
392
  'Subagent sessions can only be prompted through the foreground delegate tool.',
@@ -434,6 +440,7 @@ export class FlexHarnessPromptQueue<TScope> {
434
440
  status: 'queued',
435
441
  queuedAt: new Date().toISOString(),
436
442
  ...(scheduleKey === undefined ? {} : { scheduleKey, debounceMs }),
443
+ ...(cacheWarm ? { cacheWarm: true as const } : {}),
437
444
  prompt: normalizedPrompt,
438
445
  options: normalizedOptions,
439
446
  ...(delegationSeed === undefined ? {} : { delegationSeed }),
@@ -448,6 +455,7 @@ export class FlexHarnessPromptQueue<TScope> {
448
455
  stored.promptQueue.push(queued);
449
456
  stored.outstandingPromptsById.set(queued.queueId, queued);
450
457
  stored.outstandingPromptBytes += byteSize;
458
+ if (!cacheWarm) this.core.cacheWarm.onActivity(state.storageKey, sessionId);
451
459
  this.emitPromptQueueEvent(queued, 'prompt.queued');
452
460
  this.schedulePromptQueueDrain(state, stored);
453
461
  return {
@@ -593,7 +601,8 @@ export class FlexHarnessPromptQueue<TScope> {
593
601
  }
594
602
  run.stored.metrics.pendingGenerationId = run.runId;
595
603
  run.stored.messages.push(reservation.userMessage, reservation.assistantMessage);
596
- if (run.stored.session.agent === undefined) {
604
+ // A cache-warm turn is no turn of the user's: it gets no reversion segment of its own.
605
+ if (run.stored.session.agent === undefined && !run.cacheWarm) {
597
606
  this.core.reversions.pruneReversionState(run.stored, true);
598
607
  const captureId = this.core.turnReversionProvider
599
608
  ? this.core.reversions.reversionId('capture', run.state.storageKey, run.sessionId, run.runId)
@@ -638,10 +647,13 @@ export class FlexHarnessPromptQueue<TScope> {
638
647
  runId: run.runId,
639
648
  status: run.scheduleKey ? 'scheduled' : 'running',
640
649
  startedAt: reservation.userMessage.createdAt,
650
+ ...(run.cacheWarm ? { cacheWarm: true as const } : {}),
641
651
  };
642
- run.stored.session.updatedAt = reservation.userMessage.createdAt;
643
- const preview = run.stored.session.preview ?? sessionPreview(run.stored.messages);
644
- if (preview !== undefined) run.stored.session.preview = preview;
652
+ if (!run.cacheWarm) {
653
+ run.stored.session.updatedAt = reservation.userMessage.createdAt;
654
+ const preview = run.stored.session.preview ?? sessionPreview(run.stored.messages);
655
+ if (preview !== undefined) run.stored.session.preview = preview;
656
+ }
645
657
  });
646
658
  } catch (error) {
647
659
  if (containsStoreCommitUncertainty(error)) run.state.lifecycle = 'fenced';
@@ -683,6 +695,7 @@ export class FlexHarnessPromptQueue<TScope> {
683
695
  runId: run.runId,
684
696
  messageId: run.userMessageId,
685
697
  session: publicSnapshot(run.stored.session),
698
+ ...(run.cacheWarm ? { cacheWarm: true } : {}),
686
699
  });
687
700
  for (const message of [run.reservedUserMessage!, run.reservedAssistantMessage!]) {
688
701
  this.core.emitEvent(run.scopeId, run.stored.session, {
@@ -697,6 +710,7 @@ export class FlexHarnessPromptQueue<TScope> {
697
710
  }
698
711
 
699
712
  queued.status = run.scheduleKey ? 'scheduled' : 'starting';
713
+ this.core.cacheWarm.onRunStarted(run, queued);
700
714
  this.emitPromptQueueEvent(queued, 'prompt.started');
701
715
  queued.resolveStarted(Object.freeze({
702
716
  queueId: queued.queueId,
@@ -712,7 +726,10 @@ export class FlexHarnessPromptQueue<TScope> {
712
726
  this.core.runs.prepareGeneration(run, options, context.abortSignal),
713
727
  pendingInput: run.steering.input,
714
728
  activateOnBackgroundJobs: true,
715
- onCacheDispatch: (dispatch: plugins.ISmartAiCacheDispatch) => { run.cacheDispatch = dispatch; },
729
+ onCacheDispatch: (dispatch: plugins.ISmartAiCacheDispatch) => {
730
+ run.cacheDispatch = dispatch;
731
+ this.core.cacheWarm.onModelDispatch(run, dispatch);
732
+ },
716
733
  onInputApplied: (input: plugins.IAgentAppliedInput) => this.core.runs.applyRunSteer(run, input),
717
734
  beforeFinish: (context: { abortSignal: AbortSignal }) =>
718
735
  this.core.subagentTasks.awaitNextBackgroundSubagent(run, context.abortSignal),
@@ -745,6 +762,7 @@ export class FlexHarnessPromptQueue<TScope> {
745
762
  queuedAt: queued.queuedAt,
746
763
  ...(queued.runId ? { runId: queued.runId } : {}),
747
764
  ...(queued.scheduleKey ? { scheduleKey: queued.scheduleKey } : {}),
765
+ ...(queued.cacheWarm ? { cacheWarm: true } : {}),
748
766
  ...(queued.startedAt ? { startedAt: queued.startedAt } : {}),
749
767
  };
750
768
  }
@@ -815,6 +833,7 @@ export class FlexHarnessPromptQueue<TScope> {
815
833
  queued.rejectStarted(rejection);
816
834
  queued.rejectCompletion(rejection);
817
835
  }
836
+ this.core.cacheWarm.onPromptFinished(queued, status, error, result);
818
837
  }
819
838
 
820
839
  public cancelQueuedPrompt(
@@ -961,7 +961,8 @@ export class FlexHarnessReversions<TScope> {
961
961
  }
962
962
 
963
963
  public async prepareRunReversion(run: IActiveRun, signal: AbortSignal): Promise<void> {
964
- if (run.stored.session.agent !== undefined || !this.core.turnReversionProvider) return;
964
+ // A cache-warm turn changes no workspace, so it captures none.
965
+ if (run.stored.session.agent !== undefined || run.cacheWarm || !this.core.turnReversionProvider) return;
965
966
  const segment = run.stored.reversionSegments.find((entry) => entry.runId === run.runId);
966
967
  const captureId = segment?.captureId;
967
968
  if (!segment || !captureId) {
@@ -1163,6 +1164,11 @@ export class FlexHarnessReversions<TScope> {
1163
1164
  delete segment.workspaceReference;
1164
1165
  }
1165
1166
 
1167
+ /** Whether an undo hid turns that the next prompt would drop with their redo history. */
1168
+ public hasHiddenReversionSegments(stored: IStoredSessionState): boolean {
1169
+ return this.hiddenReversionSegments(stored).length > 0;
1170
+ }
1171
+
1166
1172
  private hiddenReversionSegments(stored: IStoredSessionState): IFlexReversionSegment[] {
1167
1173
  const firstHidden = this.reversionGroups(stored)[stored.revertCursor]?.segments[0];
1168
1174
  if (!firstHidden) return [];