@modelprofile.com/flexharness 13.1.1 → 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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.cachewarm.d.ts +64 -0
- package/dist_ts/classes.flexharness.cachewarm.js +353 -0
- package/dist_ts/classes.flexharness.callbacks.js +2 -1
- package/dist_ts/classes.flexharness.core.d.ts +4 -1
- package/dist_ts/classes.flexharness.core.js +9 -3
- package/dist_ts/classes.flexharness.d.ts +10 -1
- package/dist_ts/classes.flexharness.deletion.js +2 -1
- package/dist_ts/classes.flexharness.js +14 -1
- package/dist_ts/classes.flexharness.lifecycle.js +4 -1
- package/dist_ts/classes.flexharness.prompts.d.ts +1 -1
- package/dist_ts/classes.flexharness.prompts.js +28 -9
- package/dist_ts/classes.flexharness.reversions.d.ts +2 -0
- package/dist_ts/classes.flexharness.reversions.js +7 -2
- package/dist_ts/classes.flexharness.runs.js +25 -3
- package/dist_ts/interfaces.d.ts +89 -1
- package/dist_ts/interfaces.flexharness.d.ts +15 -0
- package/dist_ts/interfaces.js +1 -1
- package/dist_ts/plugins.d.ts +1 -1
- package/dist_ts/plugins.js +1 -1
- package/dist_ts/utils.harnessoptions.d.ts +8 -1
- package/dist_ts/utils.harnessoptions.js +50 -6
- package/dist_ts/utils.json.js +9 -2
- package/dist_ts/utils.sessionpreview.d.ts +3 -3
- package/dist_ts/utils.sessionpreview.js +5 -5
- package/dist_ts_chat_harness/classes.transcript.js +17 -4
- package/dist_ts_chat_harness/functions.mapping.d.ts +1 -0
- package/dist_ts_chat_harness/functions.mapping.js +5 -2
- package/package.json +3 -3
- package/readme.md +27 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.cachewarm.ts +433 -0
- package/ts/classes.flexharness.callbacks.ts +1 -0
- package/ts/classes.flexharness.core.ts +8 -1
- package/ts/classes.flexharness.deletion.ts +1 -0
- package/ts/classes.flexharness.lifecycle.ts +3 -0
- package/ts/classes.flexharness.prompts.ts +25 -6
- package/ts/classes.flexharness.reversions.ts +7 -1
- package/ts/classes.flexharness.runs.ts +20 -2
- package/ts/classes.flexharness.ts +21 -0
- package/ts/interfaces.flexharness.ts +16 -0
- package/ts/interfaces.ts +100 -1
- package/ts/plugins.ts +1 -0
- package/ts/readme.md +27 -0
- package/ts/utils.harnessoptions.ts +74 -5
- package/ts/utils.json.ts +8 -1
- package/ts/utils.sessionpreview.ts +4 -4
- package/ts_chat_harness/classes.transcript.ts +14 -3
- package/ts_chat_harness/functions.mapping.ts +3 -1
- 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.
|
|
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
|
-
|
|
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.
|
|
643
|
-
|
|
644
|
-
|
|
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) => {
|
|
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
|
-
|
|
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 [];
|