@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
@@ -12,6 +12,8 @@ import {
12
12
  import type {
13
13
  IFlexAgentSessionPolicy,
14
14
  IFlexAttachmentResolver,
15
+ IFlexCacheWarmClock,
16
+ IFlexCacheWarmOptions,
15
17
  IFlexCallbackLimits,
16
18
  IFlexHarnessOptions,
17
19
  IFlexHarnessStores,
@@ -46,8 +48,21 @@ export interface INormalizedProjectManagementTools {
46
48
  export interface INormalizedBuiltInTools {
47
49
  readonly renameSession: boolean;
48
50
  readonly projectManagement?: INormalizedProjectManagementTools;
51
+ readonly cacheWarm: boolean;
49
52
  }
50
53
 
54
+ export interface INormalizedCacheWarmOptions {
55
+ readonly maxDurationMs: number;
56
+ readonly maxOutputTokens: number;
57
+ readonly clock: IFlexCacheWarmClock;
58
+ }
59
+
60
+ const defaultCacheWarmMaxDurationMs = 4 * 60 * 60 * 1000;
61
+ // One call warms for at most a day; a host extends it by calling again.
62
+ const maximumCacheWarmMaxDurationMs = 24 * 60 * 60 * 1000;
63
+ const defaultCacheWarmMaxOutputTokens = 1024;
64
+ const maximumCacheWarmMaxOutputTokens = 32_768;
65
+
51
66
  const DEFAULT_CALLBACK_LIMITS: Required<IFlexCallbackLimits> = {
52
67
  maxEvents: 10_000,
53
68
  maxOutputBytes: 1024 * 1024,
@@ -242,7 +257,7 @@ function normalizeCodeModeLimits(codeMode: unknown): Readonly<IFlexCodeModeLimit
242
257
  export function normalizeBuiltInTools(
243
258
  options: IFlexHarnessOptions<unknown>['builtInTools'],
244
259
  ): INormalizedBuiltInTools {
245
- if (options === undefined) return Object.freeze({ renameSession: false });
260
+ if (options === undefined) return Object.freeze({ renameSession: false, cacheWarm: false });
246
261
  if (
247
262
  !options
248
263
  || typeof options !== 'object'
@@ -252,14 +267,19 @@ export function normalizeBuiltInTools(
252
267
  throw new FlexHarnessValidationError('builtInTools must be a plain object.');
253
268
  }
254
269
  const unsupported = Object.keys(options)
255
- .find((key) => !['renameSession', 'projectManagement'].includes(key));
270
+ .find((key) => !['renameSession', 'projectManagement', 'cacheWarm'].includes(key));
256
271
  if (unsupported) throw new FlexHarnessValidationError(`builtInTools does not support "${unsupported}".`);
257
- if (options.renameSession !== undefined && typeof options.renameSession !== 'boolean') {
258
- throw new FlexHarnessValidationError('builtInTools.renameSession must be a boolean.');
272
+ for (const key of ['renameSession', 'cacheWarm'] as const) {
273
+ if (options[key] !== undefined && typeof options[key] !== 'boolean') {
274
+ throw new FlexHarnessValidationError(`builtInTools.${key} must be a boolean.`);
275
+ }
259
276
  }
260
277
  const projectManagement = options.projectManagement;
261
278
  if (projectManagement === undefined) {
262
- return Object.freeze({ renameSession: options.renameSession ?? false });
279
+ return Object.freeze({
280
+ renameSession: options.renameSession ?? false,
281
+ cacheWarm: options.cacheWarm ?? false,
282
+ });
263
283
  }
264
284
  if (
265
285
  !projectManagement
@@ -283,6 +303,7 @@ export function normalizeBuiltInTools(
283
303
  }
284
304
  return Object.freeze({
285
305
  renameSession: options.renameSession ?? false,
306
+ cacheWarm: options.cacheWarm ?? false,
286
307
  projectManagement: Object.freeze({
287
308
  task: projectManagement.task ?? true,
288
309
  goal: projectManagement.goal ?? true,
@@ -374,6 +395,54 @@ export function normalizeSlashCommands<TScope>(
374
395
  return Object.freeze(normalized);
375
396
  }
376
397
 
398
+ const systemCacheWarmClock: IFlexCacheWarmClock = Object.freeze({
399
+ now: () => Date.now(),
400
+ setTimeout: (callback: () => void, delayMs: number) => {
401
+ const handle = setTimeout(callback, delayMs);
402
+ // A warm period never holds the process open.
403
+ handle.unref?.();
404
+ return handle;
405
+ },
406
+ clearTimeout: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>),
407
+ });
408
+
409
+ export function normalizeCacheWarmOptions(
410
+ input: IFlexHarnessOptions<unknown>['cacheWarm'],
411
+ ): INormalizedCacheWarmOptions {
412
+ if (input !== undefined && !isPlainOptions(input as unknown)) {
413
+ throw new FlexHarnessValidationError('cacheWarm must be a plain object.');
414
+ }
415
+ const options: IFlexCacheWarmOptions = input ?? {};
416
+ const unsupported = Object.keys(options)
417
+ .find((key) => !['maxDurationMs', 'maxOutputTokens', 'clock'].includes(key));
418
+ if (unsupported) throw new FlexHarnessValidationError(`cacheWarm does not support "${unsupported}".`);
419
+ const clock: IFlexCacheWarmClock = options.clock ?? systemCacheWarmClock;
420
+ if (
421
+ !clock
422
+ || typeof clock !== 'object'
423
+ || typeof clock.now !== 'function'
424
+ || typeof clock.setTimeout !== 'function'
425
+ || typeof clock.clearTimeout !== 'function'
426
+ ) {
427
+ throw new FlexHarnessValidationError('cacheWarm.clock must provide now, setTimeout and clearTimeout.');
428
+ }
429
+ return Object.freeze({
430
+ maxDurationMs: resolveBoundedPositiveInteger(
431
+ options.maxDurationMs,
432
+ 'cacheWarm.maxDurationMs',
433
+ defaultCacheWarmMaxDurationMs,
434
+ maximumCacheWarmMaxDurationMs,
435
+ ),
436
+ maxOutputTokens: resolveBoundedPositiveInteger(
437
+ options.maxOutputTokens,
438
+ 'cacheWarm.maxOutputTokens',
439
+ defaultCacheWarmMaxOutputTokens,
440
+ maximumCacheWarmMaxOutputTokens,
441
+ ),
442
+ clock,
443
+ });
444
+ }
445
+
377
446
  export function resolveBoundedPositiveInteger(
378
447
  value: number | undefined,
379
448
  name: string,
package/ts/utils.json.ts CHANGED
@@ -566,7 +566,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
566
566
  const activity = requireRecord(session.activity, `${path}.activity`);
567
567
  requireOnlyKeys(
568
568
  activity,
569
- ['runId', 'status', 'startedAt', 'completedAt', 'error', 'limit', 'rejectedPermissionId'],
569
+ ['runId', 'status', 'startedAt', 'completedAt', 'error', 'limit', 'rejectedPermissionId', 'cacheWarm'],
570
570
  `${path}.activity`,
571
571
  );
572
572
  if (!activityStatuses.includes(activity.status as (typeof activityStatuses)[number])) {
@@ -578,6 +578,9 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
578
578
  requireOptionalString(activity.error, `${path}.activity.error`);
579
579
  requireOptionalModelLimit(activity.limit, `${path}.activity.limit`);
580
580
  requireOptionalString(activity.rejectedPermissionId, `${path}.activity.rejectedPermissionId`);
581
+ if (activity.cacheWarm !== undefined && activity.cacheWarm !== true) {
582
+ throw new FlexHarnessStoreFormatError(`${path}.activity.cacheWarm must be true when present.`);
583
+ }
581
584
  const relationship = [
582
585
  session.parentSessionId,
583
586
  session.parentRunId,
@@ -776,6 +779,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
776
779
  'limit',
777
780
  'rejectedPermissionId',
778
781
  'steerId',
782
+ 'cacheWarm',
779
783
  ],
780
784
  path,
781
785
  );
@@ -799,6 +803,9 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
799
803
  throw new FlexHarnessStoreFormatError(`${path}.steerId is only valid on a user message.`);
800
804
  }
801
805
  }
806
+ if (message.cacheWarm !== undefined && message.cacheWarm !== true) {
807
+ throw new FlexHarnessStoreFormatError(`${path}.cacheWarm must be true when present.`);
808
+ }
802
809
  if (message.model !== undefined) validateModel(message.model, `${path}.model`);
803
810
  if (message.usage !== undefined) validateUsage(message.usage, `${path}.usage`);
804
811
  if (!Array.isArray(message.parts)) {
@@ -1,13 +1,13 @@
1
1
  import { FLEX_PROJECT_MANAGEMENT_LIMITS, type IFlexMessage } from './interfaces.js';
2
2
 
3
3
  /**
4
- * What a session is about before anyone titles it: the text of its first prompt that has any, as
5
- * one line (every run of whitespace becomes one space), cut to the title bound at a character
6
- * boundary. Undefined while no prompt carries text.
4
+ * What a session is about before anyone titles it: the text of its first prompt that has any,
5
+ * cache-warm prompts aside, as one line (every run of whitespace becomes one space), cut to the
6
+ * title bound at a character boundary. Undefined while no prompt carries text.
7
7
  */
8
8
  export function sessionPreview(messagesArg: readonly IFlexMessage[]): string | undefined {
9
9
  for (const message of messagesArg) {
10
- if (message.role !== 'user') continue;
10
+ if (message.role !== 'user' || message.cacheWarm) continue;
11
11
  const text = message.parts
12
12
  .map((part) => (part.type === 'text' ? part.text : ''))
13
13
  .join(' ')
@@ -180,7 +180,7 @@ export class FlexHarnessTranscript {
180
180
  public view(): IFlexHarnessTranscriptView {
181
181
  return structuredClone({
182
182
  messages: this.flexMessages.flatMap((message, index) =>
183
- flexMessageToHarnessRows(message, index, this.timings)),
183
+ message.cacheWarm ? [] : flexMessageToHarnessRows(message, index, this.timings)),
184
184
  permissions: [...this.permissionRows.values()],
185
185
  questions: [],
186
186
  status: this.status,
@@ -217,6 +217,11 @@ export class FlexHarnessTranscript {
217
217
  }
218
218
  if (event.scopeId !== this.scopeId || event.sessionId !== this.sessionId) return change;
219
219
  const timestamp = Date.parse(event.timestamp);
220
+ // A cache-warm turn's messages are held but not shown, so their events change no row.
221
+ const warmMessage = 'message' in event
222
+ ? event.message.cacheWarm === true
223
+ : 'messageId' in event && this.flexMessages.some((message) =>
224
+ message.messageId === event.messageId && message.cacheWarm);
220
225
  switch (event.type) {
221
226
  case 'session.created':
222
227
  case 'session.updated':
@@ -227,7 +232,7 @@ export class FlexHarnessTranscript {
227
232
  change.messages = change.permissions = change.status = change.usage = true;
228
233
  break;
229
234
  case 'run.started':
230
- this.setStatus(change, { type: 'busy' });
235
+ if (!event.cacheWarm) this.setStatus(change, { type: 'busy' });
231
236
  break;
232
237
  case 'message.created':
233
238
  case 'message.updated':
@@ -280,13 +285,15 @@ export class FlexHarnessTranscript {
280
285
  this.usage = sumUsage(this.usage, flexUsageToHarness(event.message.usage));
281
286
  change.usage = true;
282
287
  }
288
+ if (event.cacheWarm) break;
283
289
  this.setStatus(change, event.status === 'failed'
284
290
  ? { type: 'error', ...(event.error ? { message: event.error.message } : {}) }
285
291
  : { type: 'idle' });
286
292
  break;
287
293
  case 'prompt.started':
288
294
  case 'prompt.running':
289
- this.runningQueueId = event.queueId;
295
+ // A steer cannot join a cache-warm turn.
296
+ if (!event.entry.cacheWarm) this.runningQueueId = event.queueId;
290
297
  break;
291
298
  case 'prompt.finished':
292
299
  if (this.runningQueueId === event.queueId) this.runningQueueId = undefined;
@@ -297,6 +304,10 @@ export class FlexHarnessTranscript {
297
304
  default:
298
305
  break;
299
306
  }
307
+ if (warmMessage) {
308
+ change.messages = false;
309
+ change.deltas = [];
310
+ }
300
311
  return change;
301
312
  }
302
313
 
@@ -215,7 +215,9 @@ export function flexPermissionToHarness(
215
215
  };
216
216
  }
217
217
 
218
+ /** A cache-warm turn is idle time to the user: its activity shows as idle. */
218
219
  export function flexSessionToHarnessStatus(session: IFlexSession): plugins.IHarnessStatus {
220
+ if (session.activity.cacheWarm) return { type: 'idle' };
219
221
  switch (session.activity.status) {
220
222
  case 'running':
221
223
  case 'scheduled':
@@ -247,7 +249,7 @@ export function flexSessionToHarnessMeta(session: IFlexSession): plugins.IHarnes
247
249
  title: session.title ?? session.sessionId,
248
250
  ...(epoch(session.createdAt) === undefined ? {} : { createdAt: epoch(session.createdAt) }),
249
251
  ...(epoch(session.updatedAt) === undefined ? {} : { updatedAt: epoch(session.updatedAt) }),
250
- state: sessionCardStates[session.activity.status],
252
+ state: session.activity.cacheWarm ? 'normal' : sessionCardStates[session.activity.status],
251
253
  };
252
254
  }
253
255
 
@@ -78,6 +78,7 @@ The host forwards every event of the harness instance, of every session, in orde
78
78
  - Permission rows stay in the transcript once answered, showing the decision. A request its run leaves unanswered, because the run was stopped or failed, is removed when the run finishes.
79
79
  - FlexHarness emits no event for project state. After a run, or after a project tool's row completes, read `getProjectState()` and pass it to `applyProjectState()` for the task list and the scratchpad.
80
80
  - `questions` is always empty: FlexHarness has no question requests.
81
+ - A cache-warm turn (see the FlexHarness readme, Keeping the Prompt Cache Warm) has no rows and leaves the status idle, the session card normal and `activeQueueId` unset, so a steer never joins it. Its usage still counts in `usage`: it is spent.
81
82
  - Messages the session has not taken in yet (a queued prompt, a steer) carry no text in FlexHarness events. The host that sent them shows them as pending rows.
82
83
  - An attachment row needs a name, a media type and a size. The public projection keeps no payload and states these only when the prompt did, so an attachment without all three, such as a remote URL of unknown size, has no row.
83
84