@modelprofile.com/flexharness 13.1.1 → 13.2.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 (47) 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 +9 -1
  6. package/dist_ts/classes.flexharness.core.js +55 -6
  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 +25 -5
  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 +23 -2
  16. package/dist_ts/interfaces.d.ts +87 -1
  17. package/dist_ts/interfaces.flexharness.d.ts +15 -0
  18. package/dist_ts/interfaces.js +1 -1
  19. package/dist_ts/plugins.d.ts +1 -1
  20. package/dist_ts/plugins.js +1 -1
  21. package/dist_ts/utils.harnessoptions.d.ts +8 -1
  22. package/dist_ts/utils.harnessoptions.js +50 -6
  23. package/dist_ts/utils.json.js +11 -2
  24. package/dist_ts/utils.sessionpreview.d.ts +3 -3
  25. package/dist_ts/utils.sessionpreview.js +5 -5
  26. package/dist_ts_chat_harness/classes.transcript.js +17 -4
  27. package/package.json +3 -3
  28. package/readme.md +27 -0
  29. package/ts/00_commitinfo_data.ts +1 -1
  30. package/ts/classes.flexharness.cachewarm.ts +433 -0
  31. package/ts/classes.flexharness.callbacks.ts +1 -0
  32. package/ts/classes.flexharness.core.ts +50 -4
  33. package/ts/classes.flexharness.deletion.ts +1 -0
  34. package/ts/classes.flexharness.lifecycle.ts +3 -0
  35. package/ts/classes.flexharness.prompts.ts +22 -3
  36. package/ts/classes.flexharness.reversions.ts +7 -1
  37. package/ts/classes.flexharness.runs.ts +19 -1
  38. package/ts/classes.flexharness.ts +21 -0
  39. package/ts/interfaces.flexharness.ts +16 -0
  40. package/ts/interfaces.ts +98 -1
  41. package/ts/plugins.ts +1 -0
  42. package/ts/readme.md +27 -0
  43. package/ts/utils.harnessoptions.ts +74 -5
  44. package/ts/utils.json.ts +10 -1
  45. package/ts/utils.sessionpreview.ts +4 -4
  46. package/ts_chat_harness/classes.transcript.ts +14 -3
  47. package/ts_chat_harness/readme.md +1 -0
@@ -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,
@@ -963,32 +969,42 @@ export class FlexHarnessCore<TScope> {
963
969
  ...stored.messages.map((message) => message.runId),
964
970
  ...stored.stagedTerminals.map((terminal) => terminal.runId),
965
971
  ]);
972
+ // The session's state is the latest real turn's: a cache-warm turn's terminal never sets it.
966
973
  let latestTerminal: IFlexTerminalProjection | undefined;
967
974
  for (const runId of runIds) {
968
975
  const outcome = outcomes.get(runId);
969
976
  const stage = stages.get(runId);
970
977
  if (outcome === 'accepted' && stage?.status === 'completed') {
971
978
  this.runs.replaceVisibleRun(stored, stage);
972
- latestTerminal = stage;
979
+ if (!stage.userMessage.cacheWarm) latestTerminal = stage;
973
980
  } else if (outcome !== 'accepted') {
974
981
  const visible = stored.messages.filter((message) => message.runId === runId);
975
- if (visible.length > 0 || stage) {
982
+ // A run that failed or was stopped keeps its terminal and its error: the staged one, or the
983
+ // one it published. Only a run that never reached its terminal is repaired as interrupted.
984
+ const ended = stage && stage.status !== 'completed'
985
+ ? stage
986
+ : stage ? undefined : this.publishedTerminal(runId, visible);
987
+ if (ended) {
988
+ if (ended === stage) this.runs.replaceVisibleRun(stored, stage);
989
+ if (!ended.userMessage.cacheWarm) latestTerminal = ended;
990
+ } else if (visible.length > 0 || stage) {
976
991
  const status = outcome === 'rejected' ? 'failed' : 'cancelled';
977
992
  const terminal = this.repairTerminal(stored.session.sessionId, runId, visible, stage, status);
978
993
  this.runs.replaceVisibleRun(stored, terminal);
979
- latestTerminal = terminal;
994
+ if (!terminal.userMessage.cacheWarm) latestTerminal = terminal;
980
995
  }
981
996
  } else {
982
997
  const visibleSuccess = stored.messages.some((message) =>
983
998
  message.runId === runId && message.status === 'completed');
984
999
  if (visibleSuccess) {
985
- latestTerminal = this.repairTerminal(
1000
+ const terminal = this.repairTerminal(
986
1001
  stored.session.sessionId,
987
1002
  runId,
988
1003
  stored.messages.filter((message) => message.runId === runId),
989
1004
  undefined,
990
1005
  'completed',
991
1006
  );
1007
+ if (!terminal.userMessage.cacheWarm) latestTerminal = terminal;
992
1008
  }
993
1009
  }
994
1010
  }
@@ -1027,12 +1043,18 @@ export class FlexHarnessCore<TScope> {
1027
1043
  }
1028
1044
  await this.reversions.recoverPendingCapture(state, stored, scopeId, scope, outcomes);
1029
1045
  }
1046
+ // FlexHarness 13.2.0 marked a warm turn's activity with `cacheWarm`; no activity carries it now.
1047
+ delete (stored.session.activity as { cacheWarm?: true }).cacheWarm;
1030
1048
  if (latestTerminal) {
1031
1049
  const completedAt = latestTerminal.assistantMessage.completedAt ?? new Date().toISOString();
1050
+ const startedAt = stored.session.activity.runId === latestTerminal.runId
1051
+ ? stored.session.activity.startedAt
1052
+ : undefined;
1032
1053
  stored.session.status = latestTerminal.status === 'completed' ? 'idle' : latestTerminal.status;
1033
1054
  stored.session.activity = {
1034
1055
  runId: latestTerminal.runId,
1035
1056
  status: latestTerminal.status === 'completed' ? 'idle' : latestTerminal.status,
1057
+ ...(startedAt === undefined ? {} : { startedAt }),
1036
1058
  completedAt,
1037
1059
  ...terminalActivityError(latestTerminal.assistantMessage),
1038
1060
  };
@@ -1076,6 +1098,30 @@ export class FlexHarnessCore<TScope> {
1076
1098
  return beforeSession !== JSON.stringify(stored.session);
1077
1099
  }
1078
1100
 
1101
+ /**
1102
+ * The failed or cancelled terminal a run published, read from its projected messages. A run
1103
+ * still running has its prompt or its last assistant message streaming; it has none.
1104
+ */
1105
+ private publishedTerminal(
1106
+ runId: string,
1107
+ visible: IFlexMessage[],
1108
+ ): IFlexTerminalProjection | undefined {
1109
+ if (visible.some((message) => message.status === 'streaming')) return undefined;
1110
+ const userMessage = visible.find((message) => message.role === 'user' && message.steerId === undefined);
1111
+ const assistantMessage = [...visible].reverse().find((message) => message.role === 'assistant');
1112
+ if (!userMessage || !assistantMessage) return undefined;
1113
+ const status = assistantMessage.status;
1114
+ if (status !== 'failed' && status !== 'cancelled') return undefined;
1115
+ return {
1116
+ runId,
1117
+ status,
1118
+ userMessage,
1119
+ assistantMessage,
1120
+ ...(assistantMessage.model ? { model: assistantMessage.model } : {}),
1121
+ ...(assistantMessage.usage ? { usage: assistantMessage.usage } : {}),
1122
+ };
1123
+ }
1124
+
1079
1125
  private repairTerminal(
1080
1126
  sessionId: string,
1081
1127
  runId: string,
@@ -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)
@@ -633,6 +642,9 @@ export class FlexHarnessPromptQueue<TScope> {
633
642
  if (run.state.tombstones.has(run.sessionId) || run.state.sessions.get(run.sessionId) !== run.stored) {
634
643
  throw new FlexHarnessAbortError('The session was deleted during prompt admission.');
635
644
  }
645
+ // A cache-warm turn leaves the session's status, activity, updatedAt and preview as the
646
+ // last real turn left them; the active run alone keeps the session busy.
647
+ if (run.cacheWarm) return;
636
648
  run.stored.session.status = run.scheduleKey ? 'scheduled' : 'running';
637
649
  run.stored.session.activity = {
638
650
  runId: run.runId,
@@ -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 [];
@@ -27,6 +27,7 @@ import {
27
27
  type TFlexMessagePart,
28
28
  } from './interfaces.js';
29
29
  import { FlexToolCatalog, assertNoCatalogCollisions } from './classes.toolcatalog.js';
30
+ import { refuseToolExecution } from './classes.flexharness.cachewarm.js';
30
31
  import { cloneSerializable } from './utils.json.js';
31
32
  import type { INormalizedFlexPrompt } from './utils.prompt.js';
32
33
  import {
@@ -106,6 +107,7 @@ export class FlexHarnessRuns<TScope> {
106
107
  ...(queued.scheduleKey === undefined
107
108
  ? {}
108
109
  : { scheduleKey: queued.scheduleKey, debounceMs: queued.debounceMs }),
110
+ ...(queued.cacheWarm ? { cacheWarm: true as const } : {}),
109
111
  callbackEventCount: 0,
110
112
  callbackOutputBytes: 0,
111
113
  callbackParts: [],
@@ -527,6 +529,8 @@ export class FlexHarnessRuns<TScope> {
527
529
  run.toolCatalog = admittedCatalog;
528
530
  Object.assign(admittedTools, this.core.toolDispatch.createCatalogTools(run, admittedCatalog));
529
531
  }
532
+ // A cache-warm turn offers the same tools, as they are part of the cached prefix, but runs none.
533
+ if (run.cacheWarm) refuseToolExecution(admittedTools);
530
534
  tools = Object.keys(admittedTools).length > 0
531
535
  ? this.core.toolDispatch.trackToolCalls(run, wrapToolSet(
532
536
  admittedTools as TFlexAgentToolSet,
@@ -569,6 +573,7 @@ export class FlexHarnessRuns<TScope> {
569
573
  ...(model!.providerOptions ? { providerOptions: model!.providerOptions } : {}),
570
574
  ...(model!.cache === undefined ? {} : { cache: model!.cache }),
571
575
  ...(options.maxSteps ?? model!.maxSteps ? { maxSteps: options.maxSteps ?? model!.maxSteps } : {}),
576
+ ...(run.cacheWarm ? { maxOutputTokens: this.core.cacheWarmOptions.maxOutputTokens } : {}),
572
577
  ...(close ? { close } : {}),
573
578
  };
574
579
  } catch (error) {
@@ -593,6 +598,7 @@ export class FlexHarnessRuns<TScope> {
593
598
  names.add('delegate_send');
594
599
  }
595
600
  if (this.core.builtInTools.renameSession) names.add('rename_session');
601
+ if (this.core.builtInTools.cacheWarm && run.stored.session.agent === undefined) names.add('keep_cache_warm');
596
602
  const projectManagement = this.core.builtInTools.projectManagement;
597
603
  if (projectManagement?.task) names.add('task');
598
604
  if (projectManagement?.goal) names.add('goal');
@@ -618,6 +624,9 @@ export class FlexHarnessRuns<TScope> {
618
624
  tools.delegate_send = this.core.subagentTasks.createSubagentSendTool(run);
619
625
  }
620
626
  if (this.core.builtInTools.renameSession) tools.rename_session = this.createRenameSessionTool(run);
627
+ if (this.core.builtInTools.cacheWarm && run.stored.session.agent === undefined) {
628
+ tools.keep_cache_warm = this.core.cacheWarm.createKeepCacheWarmTool(run);
629
+ }
621
630
  const projectManagement = this.core.builtInTools.projectManagement;
622
631
  if (projectManagement?.task) tools.task = this.core.projects.createProjectTaskTool(run);
623
632
  if (projectManagement?.goal) tools.goal = this.core.projects.createProjectGoalTool(run);
@@ -685,6 +694,7 @@ export class FlexHarnessRuns<TScope> {
685
694
  status: 'streaming',
686
695
  createdAt: timestamp,
687
696
  parts: this.userMessageParts(prompt),
697
+ ...(run.cacheWarm ? { cacheWarm: true as const } : {}),
688
698
  };
689
699
  const assistantMessage: IFlexMessage = {
690
700
  messageId: run.assistantMessageId,
@@ -694,6 +704,7 @@ export class FlexHarnessRuns<TScope> {
694
704
  status: 'streaming',
695
705
  createdAt: timestamp,
696
706
  parts: [],
707
+ ...(run.cacheWarm ? { cacheWarm: true as const } : {}),
697
708
  };
698
709
  return { userMessage, assistantMessage };
699
710
  }
@@ -897,6 +908,10 @@ export class FlexHarnessRuns<TScope> {
897
908
  part.completedAt = timestamp;
898
909
  }
899
910
  }
911
+ if (run.cacheWarm) {
912
+ userMessage.cacheWarm = true;
913
+ assistantMessage.cacheWarm = true;
914
+ }
900
915
  userMessage.status = status;
901
916
  userMessage.completedAt = timestamp;
902
917
  assistantMessage.status = status;
@@ -950,7 +965,7 @@ export class FlexHarnessRuns<TScope> {
950
965
  this.replaceVisibleRun(run.stored, terminal);
951
966
  run.stored.stagedTerminals = run.stored.stagedTerminals.filter((entry) => entry.runId !== run.runId);
952
967
  }, true);
953
- if (!this.core.isTombstoned(run)) {
968
+ if (!this.core.isTombstoned(run) && !run.cacheWarm) {
954
969
  await this.core.mutateScope(run.state, () => {
955
970
  if (run.state.sessions.get(run.sessionId) !== run.stored) return;
956
971
  const timestamp = terminal.assistantMessage.completedAt!;
@@ -974,6 +989,8 @@ export class FlexHarnessRuns<TScope> {
974
989
  this.replaceVisibleRun(run.stored, terminal);
975
990
  run.stored.stagedTerminals = run.stored.stagedTerminals.filter((entry) => entry.runId !== run.runId);
976
991
  }, true);
992
+ // A failed cache-warm turn reports through `cache.warm.stopped`; the session keeps its state.
993
+ if (run.cacheWarm) return;
977
994
  await this.core.mutateScope(run.state, () => {
978
995
  if (run.state.sessions.get(run.sessionId) !== run.stored) return;
979
996
  const timestamp = terminal.assistantMessage.completedAt!;
@@ -1099,6 +1116,7 @@ export class FlexHarnessRuns<TScope> {
1099
1116
  ...(error === undefined ? {} : { error: errorToInfo(error) }),
1100
1117
  keptInContext: steering.keptInContext,
1101
1118
  unappliedSteerIds: [...steering.unappliedSteerIds],
1119
+ ...(run.cacheWarm ? { cacheWarm: true } : {}),
1102
1120
  });
1103
1121
  }
1104
1122
 
@@ -1,6 +1,8 @@
1
1
  import type {
2
2
  IFlexBackgroundExecution,
3
3
  IFlexBeginSessionImportOptions,
4
+ IFlexCacheWarmInput,
5
+ IFlexCacheWarmState,
4
6
  IFlexCompactSessionOptions,
5
7
  IFlexCreateProjectTaskInput,
6
8
  IFlexCreateSessionOptions,
@@ -54,6 +56,7 @@ export {
54
56
  createFlexResourceToolNamespace,
55
57
  createFlexResourceToolName,
56
58
  } from './utils.flexharness.js';
59
+ export { FLEX_CACHE_WARM_PROMPT } from './classes.flexharness.cachewarm.js';
57
60
 
58
61
  export class FlexHarness<TScope = unknown> {
59
62
  private readonly core: FlexHarnessCore<TScope>;
@@ -384,6 +387,24 @@ export class FlexHarness<TScope = unknown> {
384
387
  return this.core.prompts.steerPrompt(scopeId, sessionId, queueId, steer);
385
388
  }
386
389
 
390
+ /**
391
+ * Keeps the prompt cache of a root session warm until `durationMs` from now, capped at
392
+ * `cacheWarm.maxDurationMs`; a further call replaces the deadline and `0` stops it. While the
393
+ * session is idle, a marked cache-warm prompt reads the cached prefix again shortly before it
394
+ * would expire. The `keep_cache_warm` built-in tool gives the agent the same control.
395
+ */
396
+ public keepCacheWarm(
397
+ scopeId: string,
398
+ sessionId: string,
399
+ input: IFlexCacheWarmInput,
400
+ ): Promise<IFlexCacheWarmState> {
401
+ return this.core.cacheWarm.keepCacheWarm(scopeId, sessionId, input);
402
+ }
403
+
404
+ public getCacheWarm(scopeId: string, sessionId: string): Promise<IFlexCacheWarmState> {
405
+ return this.core.cacheWarm.getCacheWarm(scopeId, sessionId);
406
+ }
407
+
387
408
  /** Sends follow-up input only to an exact running direct child owned by this parent run. */
388
409
  public sendSubagentInput(
389
410
  scopeId: string,
@@ -102,6 +102,18 @@ export interface IStoredSessionState {
102
102
  terminalPromptQueueEntries: Map<string, IFlexPromptQueueEntry>;
103
103
  outstandingPromptBytes: number;
104
104
  promptQueueDrain?: Promise<void>;
105
+ /** The last model call of the session in this process, on the cache keeper's clock. */
106
+ lastCacheDispatch?: ICacheDispatchMark;
107
+ /** What the last root prompt asked of the model, which its cache-warm turns repeat. */
108
+ cacheWarmPromptOptions?: Pick<IFlexPromptOptions, 'modelHint' | 'system'>;
109
+ }
110
+
111
+ export interface ICacheDispatchMark {
112
+ at: number;
113
+ /** How long after `at` a cache-warm turn is due: the cache's TTL less a minute. */
114
+ idleMs: number;
115
+ /** What the TTL rests on; absent for the five-minute default. */
116
+ basis?: plugins.TSmartAiCacheBudgetBasis;
105
117
  }
106
118
 
107
119
  export interface IRetainedSessionCleanup {
@@ -220,6 +232,8 @@ export interface IActiveRun {
220
232
  transaction?: plugins.IAgentGenerationHandle;
221
233
  scheduleKey?: string;
222
234
  debounceMs?: number;
235
+ /** A cache-warm turn of the session's cache keeper; see `keepCacheWarm()`. */
236
+ cacheWarm?: true;
223
237
  ownerCancellation?: FlexHarnessAbortError;
224
238
  internalFailure?: unknown;
225
239
  callbackError?: unknown;
@@ -389,6 +403,8 @@ export interface IQueuedPrompt {
389
403
  runId?: string;
390
404
  scheduleKey?: string;
391
405
  debounceMs?: number;
406
+ /** A cache-warm prompt of the session's cache keeper; see `keepCacheWarm()`. */
407
+ cacheWarm?: true;
392
408
  startedAt?: string;
393
409
  prompt?: INormalizedFlexPrompt;
394
410
  options?: IFlexPromptOptions;
package/ts/interfaces.ts CHANGED
@@ -222,6 +222,11 @@ export interface IFlexMessage {
222
222
  rejectedPermissionId?: string;
223
223
  /** On a user message that steered its running run: the caller's steer identity. */
224
224
  steerId?: string;
225
+ /**
226
+ * On both messages of a cache-warm turn: the harness sent the prompt only to keep the model's
227
+ * prompt cache warm. Hosts leave these out of what they show and report as work.
228
+ */
229
+ cacheWarm?: true;
225
230
  }
226
231
 
227
232
  export interface IFlexSessionActivity {
@@ -523,6 +528,8 @@ export interface IFlexPromptQueueEntry {
523
528
  queuedAt: string;
524
529
  runId?: string;
525
530
  scheduleKey?: string;
531
+ /** The entry is a cache-warm prompt; see {@link IFlexMessage.cacheWarm}. */
532
+ cacheWarm?: true;
526
533
  startedAt?: string;
527
534
  finishedAt?: string;
528
535
  error?: IFlexErrorInfo;
@@ -1897,8 +1904,50 @@ export interface IFlexProjectManagementBuiltInToolsOptions {
1897
1904
  export interface IFlexBuiltInToolsOptions {
1898
1905
  renameSession?: boolean;
1899
1906
  projectManagement?: IFlexProjectManagementBuiltInToolsOptions;
1907
+ /** The `keep_cache_warm` tool of root sessions; see `FlexHarness.keepCacheWarm()`. */
1908
+ cacheWarm?: boolean;
1909
+ }
1910
+
1911
+ /** The clock the cache keeper reads and sets its timers on. Tests pass a fake one. */
1912
+ export interface IFlexCacheWarmClock {
1913
+ now(): number;
1914
+ setTimeout(callback: () => void, delayMs: number): unknown;
1915
+ clearTimeout(handle: unknown): void;
1916
+ }
1917
+
1918
+ export interface IFlexCacheWarmOptions {
1919
+ /** The longest warm period one call sets, in milliseconds. Defaults to 4 hours. */
1920
+ maxDurationMs?: number;
1921
+ /** Caps the output tokens of each model call of a cache-warm turn. Defaults to 1024. */
1922
+ maxOutputTokens?: number;
1923
+ clock?: IFlexCacheWarmClock;
1924
+ }
1925
+
1926
+ /** What `keepCacheWarm()` sets. */
1927
+ export interface IFlexCacheWarmInput {
1928
+ /** How long from now to keep the cache warm, in milliseconds; 0 stops. Capped at the maximum. */
1929
+ durationMs: number;
1900
1930
  }
1901
1931
 
1932
+ /** A session's cache keeper. Process-local: it ends with the harness. */
1933
+ export interface IFlexCacheWarmState {
1934
+ sessionId: string;
1935
+ /** Present while the keeper runs: when it stops, as an ISO timestamp. */
1936
+ warmUntil?: string;
1937
+ /** Present while a warm turn is due: when, as an ISO timestamp. */
1938
+ nextWarmAt?: string;
1939
+ /** The idle time after a model call at which a warm turn is due, in milliseconds. */
1940
+ idleMs?: number;
1941
+ /**
1942
+ * What `idleMs` rests on, from the last model call: the observed cache lifetime of its provider,
1943
+ * model and endpoint (see `TSmartAiCacheBudgetBasis` of `@modelprofile.com/flexharness-models`).
1944
+ * Absent while no call was observed, when the five-minute default applies.
1945
+ */
1946
+ basis?: TFlexCacheWarmBasis;
1947
+ }
1948
+
1949
+ export type TFlexCacheWarmBasis = plugins.TSmartAiCacheBudgetBasis;
1950
+
1902
1951
  /** What an attachment resolver is asked for: the attachment `url` names, for one session. */
1903
1952
  export interface IFlexAttachmentRequest<TScope = unknown> extends IFlexAgentContextInvocation<TScope> {
1904
1953
  readonly url: URL;
@@ -1955,6 +2004,8 @@ export interface IFlexHarnessOptions<TScope> {
1955
2004
  /** Maximum child depth (root is 0). Defaults to 6; configurable from 1 through 8. */
1956
2005
  maxSubagentDepth?: number;
1957
2006
  maxSubagentCallsPerRun?: number;
2007
+ /** Bounds and the clock of the cache keeper; see `keepCacheWarm()`. */
2008
+ cacheWarm?: IFlexCacheWarmOptions;
1958
2009
  }
1959
2010
 
1960
2011
  export interface IFlexUncertainToolExecution {
@@ -2021,6 +2072,8 @@ export interface IFlexRunStartedEvent extends IFlexEventBase {
2021
2072
  readonly runId: string;
2022
2073
  readonly messageId: string;
2023
2074
  readonly session: IFlexSession;
2075
+ /** The run is a cache-warm turn; see {@link IFlexMessage.cacheWarm}. */
2076
+ readonly cacheWarm?: true;
2024
2077
  }
2025
2078
 
2026
2079
  export interface IFlexMessageChangedEvent extends IFlexEventBase {
@@ -2099,6 +2152,8 @@ export interface IFlexRunFinishedEvent extends IFlexEventBase {
2099
2152
  * steer it accepted.
2100
2153
  */
2101
2154
  readonly unappliedSteerIds: readonly string[];
2155
+ /** The run is a cache-warm turn; see {@link IFlexMessage.cacheWarm}. */
2156
+ readonly cacheWarm?: true;
2102
2157
  }
2103
2158
 
2104
2159
  /** The running run accepted a steer; it is taken in at the run's next step boundary. */
@@ -2138,6 +2193,8 @@ export type TFlexModelUsageEvent = IFlexEventBase & {
2138
2193
  readonly type: 'model.usage';
2139
2194
  readonly runId?: string;
2140
2195
  readonly source: 'generation' | 'compaction';
2196
+ /** The call belongs to a cache-warm turn; see {@link IFlexMessage.cacheWarm}. */
2197
+ readonly cacheWarm?: true;
2141
2198
  } & Readonly<TFlexObservedModelCallUsage>;
2142
2199
 
2143
2200
  export interface IFlexPromptQueueEvent extends IFlexEventBase {
@@ -2153,6 +2210,43 @@ export interface IFlexSessionHistoryChangedEvent extends IFlexEventBase {
2153
2210
  readonly runId?: string;
2154
2211
  }
2155
2212
 
2213
+ /**
2214
+ * The session's cache keeper changed: `warmUntil` is when it stops, absent once it stopped.
2215
+ * `source` is who set it: the host through `keepCacheWarm()` or the agent through its tool.
2216
+ */
2217
+ export interface IFlexCacheWarmChangedEvent extends IFlexEventBase {
2218
+ readonly type: 'cache.warm.changed';
2219
+ readonly source: 'host' | 'agent';
2220
+ readonly warmUntil?: string;
2221
+ }
2222
+
2223
+ /** A cache-warm turn completed; `usage` shows whether it read the cached prefix. */
2224
+ export interface IFlexCacheWarmCompletedEvent extends IFlexEventBase {
2225
+ readonly type: 'cache.warm.completed';
2226
+ readonly runId: string;
2227
+ /**
2228
+ * The turn's usage. `cacheReadTokens` and `cacheWriteTokens` are the provider's own counts:
2229
+ * OpenAI's `input_tokens_details.cached_tokens` and `cache_write_tokens`, Anthropic's
2230
+ * `cache_read_input_tokens` and `cache_creation_input_tokens`.
2231
+ */
2232
+ readonly usage: IFlexUsage;
2233
+ /** How long the session had been idle since its previous model call when the turn was due. */
2234
+ readonly idleForMs?: number;
2235
+ /** What the keeper's timing rested on; see {@link IFlexCacheWarmState.basis}. */
2236
+ readonly basis?: TFlexCacheWarmBasis;
2237
+ }
2238
+
2239
+ /**
2240
+ * The session's cache keeper stopped: its deadline passed, the host or agent cleared it, a warm
2241
+ * turn failed, or the session can no longer be warmed.
2242
+ */
2243
+ export interface IFlexCacheWarmStoppedEvent extends IFlexEventBase {
2244
+ readonly type: 'cache.warm.stopped';
2245
+ readonly reason: 'deadline' | 'cleared' | 'failed' | 'unavailable';
2246
+ readonly runId?: string;
2247
+ readonly error?: IFlexErrorInfo;
2248
+ }
2249
+
2156
2250
  export interface IFlexErrorInfo {
2157
2251
  name: string;
2158
2252
  message: string;
@@ -2178,6 +2272,9 @@ export type TFlexHarnessEvent =
2178
2272
  | IFlexPromptQueueEvent
2179
2273
  | IFlexSteerAcceptedEvent
2180
2274
  | IFlexSteerAppliedEvent
2181
- | IFlexSessionHistoryChangedEvent;
2275
+ | IFlexSessionHistoryChangedEvent
2276
+ | IFlexCacheWarmChangedEvent
2277
+ | IFlexCacheWarmCompletedEvent
2278
+ | IFlexCacheWarmStoppedEvent;
2182
2279
 
2183
2280
  export type TFlexHarnessEventListener = (event: TFlexHarnessEvent) => void;
package/ts/plugins.ts CHANGED
@@ -138,6 +138,7 @@ export type {
138
138
  IAgentAppliedInput,
139
139
  IAgentResolvedAttachment,
140
140
  ISmartAiCacheDispatch,
141
+ TSmartAiCacheBudgetBasis,
141
142
  IAgentEventArchive,
142
143
  IAgentEventChange,
143
144
  IAgentContextCompactionOptions,