@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
@@ -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;
@@ -960,8 +975,9 @@ export class FlexHarnessRuns<TScope> {
960
975
  status: 'idle',
961
976
  startedAt: run.stored.session.activity.startedAt,
962
977
  completedAt: timestamp,
978
+ ...(run.cacheWarm ? { cacheWarm: true as const } : {}),
963
979
  };
964
- run.stored.session.updatedAt = timestamp;
980
+ if (!run.cacheWarm) run.stored.session.updatedAt = timestamp;
965
981
  }, true);
966
982
  }
967
983
  }
@@ -985,8 +1001,9 @@ export class FlexHarnessRuns<TScope> {
985
1001
  startedAt: run.stored.session.activity.startedAt,
986
1002
  completedAt: timestamp,
987
1003
  ...terminalActivityError(terminal.assistantMessage),
1004
+ ...(run.cacheWarm ? { cacheWarm: true as const } : {}),
988
1005
  };
989
- run.stored.session.updatedAt = timestamp;
1006
+ if (!run.cacheWarm) run.stored.session.updatedAt = timestamp;
990
1007
  }, true);
991
1008
  }
992
1009
 
@@ -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,
@@ -586,6 +586,7 @@ class FlexToolCatalogCore {
586
586
  public readonly validators = new Map<string, plugins.JsonSchemaValidator>();
587
587
  public readonly selfContainedInputs = new Map<string, TJsonSchemaNode>();
588
588
  public readonly index: CatalogSearchIndex;
589
+ private methodDescribedTypeNames?: ReadonlySet<string>;
589
590
 
590
591
  constructor(
591
592
  public readonly mode: TDeclarationMode,
@@ -995,6 +996,51 @@ class FlexToolCatalogCore {
995
996
  return [...result].sort(compareCodePoints);
996
997
  }
997
998
 
999
+ /**
1000
+ * The named types the model reads off a method's description in this mode: those its TypeScript
1001
+ * declaration names, or those the `$ref`s of its JSON Schemas name.
1002
+ */
1003
+ public describedTypes(entry: IMethodEntry): string[] {
1004
+ if (this.mode === 'typescript') return this.renderMethod(entry).types;
1005
+ return [
1006
+ ...this.referencedTypes(entry.input.json, entry.inputScope),
1007
+ ...(entry.output && entry.outputScope ? this.referencedTypes(entry.output.json, entry.outputScope) : []),
1008
+ ];
1009
+ }
1010
+
1011
+ /** `names` and every type the model reads off their descriptions in this mode, transitively. */
1012
+ public describedTypeClosure(names: Iterable<string>): Set<string> {
1013
+ const result = new Set<string>();
1014
+ const pending = [...names];
1015
+ while (pending.length > 0) {
1016
+ const name = pending.pop()!;
1017
+ if (result.has(name) || !this.types.has(name)) continue;
1018
+ result.add(name);
1019
+ const type = this.types.get(name)!;
1020
+ const schema = type.definition ?? type.schema?.json;
1021
+ pending.push(...(this.mode === 'typescript' || schema === undefined
1022
+ ? this.renderType(name).types
1023
+ : this.referencedTypes(schema, type.scope!)));
1024
+ }
1025
+ return result;
1026
+ }
1027
+
1028
+ /** The named types some method's description reaches, transitively. */
1029
+ public get methodDescribedTypes(): ReadonlySet<string> {
1030
+ if (this.methodDescribedTypeNames) return this.methodDescribedTypeNames;
1031
+ this.methodDescribedTypeNames = this.describedTypeClosure(
1032
+ [...this.methods.values()].flatMap((entry) => this.describedTypes(entry)),
1033
+ );
1034
+ return this.methodDescribedTypeNames;
1035
+ }
1036
+
1037
+ private referencedTypes(root: TJsonSchemaNode, scope: ISchemaScope): string[] {
1038
+ const refs = new Set<string>();
1039
+ collectRefs(root, refs);
1040
+ const context = this.scopeContext(scope);
1041
+ return [...refs].flatMap((ref) => context.namedType(ref) ?? []);
1042
+ }
1043
+
998
1044
  /** The method's input schema with the catalog types it refers to added as definitions. */
999
1045
  public selfContainedInput(entry: IMethodEntry): TJsonSchemaNode {
1000
1046
  const cached = this.selfContainedInputs.get(entry.path);
@@ -1041,6 +1087,7 @@ class FlexToolCatalogCore {
1041
1087
  */
1042
1088
  export class FlexToolCatalog<TScope = unknown> {
1043
1089
  private namespaceTree?: Map<string, { methods: IMethodEntry[]; children: Map<string, number> }>;
1090
+ private knownTypeNames?: ReadonlySet<string>;
1044
1091
 
1045
1092
  private constructor(
1046
1093
  private readonly core: FlexToolCatalogCore,
@@ -1125,12 +1172,16 @@ export class FlexToolCatalog<TScope = unknown> {
1125
1172
  return [...this.core.methods.keys()].filter((path) => this.isAdmitted(path));
1126
1173
  }
1127
1174
 
1128
- /** Every named type, sorted. */
1175
+ /** Every named type the catalog knows, sorted. */
1129
1176
  public get types(): string[] {
1130
- return [...this.core.types.keys()];
1177
+ return [...this.core.types.keys()].filter((name) => this.hasType(name));
1131
1178
  }
1132
1179
 
1133
- /** The same catalog offering only `paths`. */
1180
+ /**
1181
+ * The same catalog offering only `paths`. It knows the named types these methods' descriptions
1182
+ * reach, those no method's description reaches, and the types these reach in turn: a type only
1183
+ * the other methods reach is unknown to it.
1184
+ */
1134
1185
  public restrict(paths: Iterable<string>): FlexToolCatalog<TScope> {
1135
1186
  const allowed = new Set(paths);
1136
1187
  return new FlexToolCatalog<TScope>(
@@ -1322,8 +1373,10 @@ export class FlexToolCatalog<TScope = unknown> {
1322
1373
  .map((path) => this.core.methods.get(path)!);
1323
1374
  const unknownMethods = requestedMethods.filter((path) => !this.has(path)).sort(compareCodePoints);
1324
1375
  // A type is asked for by its identifier or by the name its schema gives it.
1325
- const identifierOf = (name: string): string | undefined =>
1326
- this.core.types.has(name) ? name : this.core.typeIdentifiers.get(name);
1376
+ const identifierOf = (name: string): string | undefined => {
1377
+ const identifier = this.core.types.has(name) ? name : this.core.typeIdentifiers.get(name);
1378
+ return identifier !== undefined && this.hasType(identifier) ? identifier : undefined;
1379
+ };
1327
1380
  const knownTypes = [...new Set(requestedTypes.flatMap((name) => identifierOf(name) ?? []))].sort(compareCodePoints);
1328
1381
  const unknownTypes = requestedTypes.filter((name) => identifierOf(name) === undefined).sort(compareCodePoints);
1329
1382
  const notes = [
@@ -1332,7 +1385,7 @@ export class FlexToolCatalog<TScope = unknown> {
1332
1385
  return `Unknown method ${path}.${suggestions.length ? ` Did you mean ${suggestions.join(', ')}?` : ' Find methods with search_tools.'}`;
1333
1386
  }),
1334
1387
  ...unknownTypes.map((name) => {
1335
- const suggestions = closeMatches(name, this.core.types.keys());
1388
+ const suggestions = closeMatches(name, this.types);
1336
1389
  return `Unknown type ${name}.${suggestions.length ? ` Did you mean ${suggestions.join(', ')}?` : ''}`;
1337
1390
  }),
1338
1391
  ];
@@ -1354,7 +1407,7 @@ export class FlexToolCatalog<TScope = unknown> {
1354
1407
  const output = part.output as { type?: string; value?: unknown };
1355
1408
  if (output.type !== 'text' || typeof output.value !== 'string') continue;
1356
1409
  for (const [, header] of output.value.matchAll(declaredTypesLine)) {
1357
- for (const name of header.split(', ')) if (this.core.types.has(name)) declared.add(name);
1410
+ for (const name of header.split(', ')) if (this.hasType(name)) declared.add(name);
1358
1411
  }
1359
1412
  }
1360
1413
  }
@@ -1440,6 +1493,22 @@ export class FlexToolCatalog<TScope = unknown> {
1440
1493
  return this.admitted === undefined || this.admitted.has(path);
1441
1494
  }
1442
1495
 
1496
+ private hasType(name: string): boolean {
1497
+ return this.core.types.has(name) && (this.admitted === undefined || this.knownTypes().has(name));
1498
+ }
1499
+
1500
+ /** The named types a restricted catalog knows; see `restrict()`. */
1501
+ private knownTypes(): ReadonlySet<string> {
1502
+ if (this.knownTypeNames) return this.knownTypeNames;
1503
+ const reached = this.core.methodDescribedTypes;
1504
+ this.knownTypeNames = this.core.describedTypeClosure([
1505
+ ...[...this.core.methods.values()].filter((entry) => this.isAdmitted(entry.path))
1506
+ .flatMap((entry) => this.core.describedTypes(entry)),
1507
+ ...[...this.core.types.keys()].filter((name) => !reached.has(name)),
1508
+ ]);
1509
+ return this.knownTypeNames;
1510
+ }
1511
+
1443
1512
  private tree(): Map<string, { methods: IMethodEntry[]; children: Map<string, number> }> {
1444
1513
  if (this.namespaceTree) return this.namespaceTree;
1445
1514
  const tree = new Map<string, { methods: IMethodEntry[]; children: Map<string, number> }>();
@@ -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 {
@@ -234,6 +239,8 @@ export interface IFlexSessionActivity {
234
239
  limit?: TFlexModelLimit;
235
240
  /** The permission whose rejection stopped the last run, next to its error. */
236
241
  rejectedPermissionId?: string;
242
+ /** The run is or was a cache-warm turn; see {@link IFlexMessage.cacheWarm}. */
243
+ cacheWarm?: true;
237
244
  }
238
245
 
239
246
  export interface IFlexSession {
@@ -523,6 +530,8 @@ export interface IFlexPromptQueueEntry {
523
530
  queuedAt: string;
524
531
  runId?: string;
525
532
  scheduleKey?: string;
533
+ /** The entry is a cache-warm prompt; see {@link IFlexMessage.cacheWarm}. */
534
+ cacheWarm?: true;
526
535
  startedAt?: string;
527
536
  finishedAt?: string;
528
537
  error?: IFlexErrorInfo;
@@ -1897,8 +1906,50 @@ export interface IFlexProjectManagementBuiltInToolsOptions {
1897
1906
  export interface IFlexBuiltInToolsOptions {
1898
1907
  renameSession?: boolean;
1899
1908
  projectManagement?: IFlexProjectManagementBuiltInToolsOptions;
1909
+ /** The `keep_cache_warm` tool of root sessions; see `FlexHarness.keepCacheWarm()`. */
1910
+ cacheWarm?: boolean;
1911
+ }
1912
+
1913
+ /** The clock the cache keeper reads and sets its timers on. Tests pass a fake one. */
1914
+ export interface IFlexCacheWarmClock {
1915
+ now(): number;
1916
+ setTimeout(callback: () => void, delayMs: number): unknown;
1917
+ clearTimeout(handle: unknown): void;
1918
+ }
1919
+
1920
+ export interface IFlexCacheWarmOptions {
1921
+ /** The longest warm period one call sets, in milliseconds. Defaults to 4 hours. */
1922
+ maxDurationMs?: number;
1923
+ /** Caps the output tokens of each model call of a cache-warm turn. Defaults to 1024. */
1924
+ maxOutputTokens?: number;
1925
+ clock?: IFlexCacheWarmClock;
1926
+ }
1927
+
1928
+ /** What `keepCacheWarm()` sets. */
1929
+ export interface IFlexCacheWarmInput {
1930
+ /** How long from now to keep the cache warm, in milliseconds; 0 stops. Capped at the maximum. */
1931
+ durationMs: number;
1900
1932
  }
1901
1933
 
1934
+ /** A session's cache keeper. Process-local: it ends with the harness. */
1935
+ export interface IFlexCacheWarmState {
1936
+ sessionId: string;
1937
+ /** Present while the keeper runs: when it stops, as an ISO timestamp. */
1938
+ warmUntil?: string;
1939
+ /** Present while a warm turn is due: when, as an ISO timestamp. */
1940
+ nextWarmAt?: string;
1941
+ /** The idle time after a model call at which a warm turn is due, in milliseconds. */
1942
+ idleMs?: number;
1943
+ /**
1944
+ * What `idleMs` rests on, from the last model call: the observed cache lifetime of its provider,
1945
+ * model and endpoint (see `TSmartAiCacheBudgetBasis` of `@modelprofile.com/flexharness-models`).
1946
+ * Absent while no call was observed, when the five-minute default applies.
1947
+ */
1948
+ basis?: TFlexCacheWarmBasis;
1949
+ }
1950
+
1951
+ export type TFlexCacheWarmBasis = plugins.TSmartAiCacheBudgetBasis;
1952
+
1902
1953
  /** What an attachment resolver is asked for: the attachment `url` names, for one session. */
1903
1954
  export interface IFlexAttachmentRequest<TScope = unknown> extends IFlexAgentContextInvocation<TScope> {
1904
1955
  readonly url: URL;
@@ -1955,6 +2006,8 @@ export interface IFlexHarnessOptions<TScope> {
1955
2006
  /** Maximum child depth (root is 0). Defaults to 6; configurable from 1 through 8. */
1956
2007
  maxSubagentDepth?: number;
1957
2008
  maxSubagentCallsPerRun?: number;
2009
+ /** Bounds and the clock of the cache keeper; see `keepCacheWarm()`. */
2010
+ cacheWarm?: IFlexCacheWarmOptions;
1958
2011
  }
1959
2012
 
1960
2013
  export interface IFlexUncertainToolExecution {
@@ -2021,6 +2074,8 @@ export interface IFlexRunStartedEvent extends IFlexEventBase {
2021
2074
  readonly runId: string;
2022
2075
  readonly messageId: string;
2023
2076
  readonly session: IFlexSession;
2077
+ /** The run is a cache-warm turn; see {@link IFlexMessage.cacheWarm}. */
2078
+ readonly cacheWarm?: true;
2024
2079
  }
2025
2080
 
2026
2081
  export interface IFlexMessageChangedEvent extends IFlexEventBase {
@@ -2099,6 +2154,8 @@ export interface IFlexRunFinishedEvent extends IFlexEventBase {
2099
2154
  * steer it accepted.
2100
2155
  */
2101
2156
  readonly unappliedSteerIds: readonly string[];
2157
+ /** The run is a cache-warm turn; see {@link IFlexMessage.cacheWarm}. */
2158
+ readonly cacheWarm?: true;
2102
2159
  }
2103
2160
 
2104
2161
  /** The running run accepted a steer; it is taken in at the run's next step boundary. */
@@ -2138,6 +2195,8 @@ export type TFlexModelUsageEvent = IFlexEventBase & {
2138
2195
  readonly type: 'model.usage';
2139
2196
  readonly runId?: string;
2140
2197
  readonly source: 'generation' | 'compaction';
2198
+ /** The call belongs to a cache-warm turn; see {@link IFlexMessage.cacheWarm}. */
2199
+ readonly cacheWarm?: true;
2141
2200
  } & Readonly<TFlexObservedModelCallUsage>;
2142
2201
 
2143
2202
  export interface IFlexPromptQueueEvent extends IFlexEventBase {
@@ -2153,6 +2212,43 @@ export interface IFlexSessionHistoryChangedEvent extends IFlexEventBase {
2153
2212
  readonly runId?: string;
2154
2213
  }
2155
2214
 
2215
+ /**
2216
+ * The session's cache keeper changed: `warmUntil` is when it stops, absent once it stopped.
2217
+ * `source` is who set it: the host through `keepCacheWarm()` or the agent through its tool.
2218
+ */
2219
+ export interface IFlexCacheWarmChangedEvent extends IFlexEventBase {
2220
+ readonly type: 'cache.warm.changed';
2221
+ readonly source: 'host' | 'agent';
2222
+ readonly warmUntil?: string;
2223
+ }
2224
+
2225
+ /** A cache-warm turn completed; `usage` shows whether it read the cached prefix. */
2226
+ export interface IFlexCacheWarmCompletedEvent extends IFlexEventBase {
2227
+ readonly type: 'cache.warm.completed';
2228
+ readonly runId: string;
2229
+ /**
2230
+ * The turn's usage. `cacheReadTokens` and `cacheWriteTokens` are the provider's own counts:
2231
+ * OpenAI's `input_tokens_details.cached_tokens` and `cache_write_tokens`, Anthropic's
2232
+ * `cache_read_input_tokens` and `cache_creation_input_tokens`.
2233
+ */
2234
+ readonly usage: IFlexUsage;
2235
+ /** How long the session had been idle since its previous model call when the turn was due. */
2236
+ readonly idleForMs?: number;
2237
+ /** What the keeper's timing rested on; see {@link IFlexCacheWarmState.basis}. */
2238
+ readonly basis?: TFlexCacheWarmBasis;
2239
+ }
2240
+
2241
+ /**
2242
+ * The session's cache keeper stopped: its deadline passed, the host or agent cleared it, a warm
2243
+ * turn failed, or the session can no longer be warmed.
2244
+ */
2245
+ export interface IFlexCacheWarmStoppedEvent extends IFlexEventBase {
2246
+ readonly type: 'cache.warm.stopped';
2247
+ readonly reason: 'deadline' | 'cleared' | 'failed' | 'unavailable';
2248
+ readonly runId?: string;
2249
+ readonly error?: IFlexErrorInfo;
2250
+ }
2251
+
2156
2252
  export interface IFlexErrorInfo {
2157
2253
  name: string;
2158
2254
  message: string;
@@ -2178,6 +2274,9 @@ export type TFlexHarnessEvent =
2178
2274
  | IFlexPromptQueueEvent
2179
2275
  | IFlexSteerAcceptedEvent
2180
2276
  | IFlexSteerAppliedEvent
2181
- | IFlexSessionHistoryChangedEvent;
2277
+ | IFlexSessionHistoryChangedEvent
2278
+ | IFlexCacheWarmChangedEvent
2279
+ | IFlexCacheWarmCompletedEvent
2280
+ | IFlexCacheWarmStoppedEvent;
2182
2281
 
2183
2282
  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,
package/ts/readme.md CHANGED
@@ -418,6 +418,7 @@ The tools use strict action-discriminated inputs:
418
418
  - `goal`: `get`, `set`, or `clear`.
419
419
  - `scratchpad`: `get`, `set`, `append`, or `clear`. Append concatenates the supplied content exactly.
420
420
  - `rename_session`: sets the active session title and returns the authoritative session.
421
+ - `keep_cache_warm` (`cacheWarm: true`, root sessions only): keeps the session's prompt cache warm; see [Keeping the Prompt Cache Warm](#keeping-the-prompt-cache-warm).
421
422
 
422
423
  Every project action returns the authoritative revision and state; task mutations also return the affected task, and clear returns the removed tasks. Reads never save. A set, clear, append, update, idempotent create, or empty task clear that makes no state change returns the current revision without writing. Mutations load once, apply once, validate the complete next snapshot, and issue one compare-and-swap save at `revision + 1`. FlexHarness never retries or merges an external conflict.
423
424
 
@@ -729,6 +730,32 @@ The run takes a steer in at its next step boundary: after the tool results of it
729
730
 
730
731
  `steerPrompt()` resolves with `{ steerId, queueId, runId }` once the run accepted the steer and `prompt.steer.accepted` was emitted; `prompt.steer.applied` carries the `messageId` of the user message the steer became. Every accepted steer ends in exactly one of two ways: it is in the model context of later turns, or it is listed in `unappliedSteerIds`, in arrival order, on `IFlexPromptResult` and `run.finished`, and on the finished queue entry when that list is not empty. A steer is listed when the run did not take it in before it ended, because its last model step was already running or because it was stopped or failed, and also when the run itself does not stay in context. `keptInContext` on `run.finished` and on the finished queue entry says whether it does: it is `false` for a failed run and for a run stopped before any model output, and such a run lists every steer it accepted, including the ones it took in and reported with `prompt.steer.applied`. Send its prompt and its unapplied steers again to have them answered. The run stops taking steers atomically when its generation ends or `abort()`/`cancelPrompt()` stops it. A steer for a prompt without a running run, or whose run is ending, is refused with `FlexHarnessSteerRejectedError` reason `not-running`; the caller sends it as the next turn instead. Reusing a `steerId` within a run is refused with reason `duplicate`, an unknown `queueId` throws `FlexHarnessNotFoundError`, and child sessions reject this general API (their exact parent run uses `sendSubagentInput()` or `delegate_send` instead). A run holds at most `maxOutstandingPromptsPerSession` pending steers, and their bytes count against the session's outstanding prompt byte limit until the run takes them in or ends, so `startPrompt()` and `enqueuePrompt()` are refused with `FlexHarnessQueueFullError` while pending steers fill it. They are process-local like queue entries: a process restart ends the run and drops its pending steers, while steers it took in are durable parts of the turn.
731
732
 
733
+ ### Keeping the Prompt Cache Warm
734
+
735
+ A provider's prompt cache expires when nothing reads it for its TTL, and the next turn then writes the whole prefix again. `keepCacheWarm()` keeps a root session's cache warm until a deadline:
736
+
737
+ ```typescript
738
+ await harness.keepCacheWarm(scopeId, sessionId, { durationMs: 60 * 60 * 1000 }); // warm until 1 h from now
739
+ await harness.keepCacheWarm(scopeId, sessionId, { durationMs: 0 }); // stop
740
+ const state = await harness.getCacheWarm(scopeId, sessionId); // { sessionId, warmUntil?, nextWarmAt?, idleMs?, basis? }
741
+ ```
742
+
743
+ Each call replaces the deadline with `durationMs` from now, capped at `cacheWarm.maxDurationMs` (default 4 h, at most 24 h); `0` stops it. While the deadline has not passed and the session is idle, the harness sends a cache-warm prompt once the session has had no model call for the cache TTL less one minute: 4 minutes for a 5-minute cache, 29 for a 30-minute one, 59 for a 1-hour one. The TTL comes from the request the provider actually sent last, its `basis` in `getCacheWarm()`:
744
+
745
+ | `basis` | TTL | Sent request |
746
+ | --- | --- | --- |
747
+ | `anthropic-5m`, `anthropic-1h` | 5 min, 1 h | Anthropic: the shortest `cache_control` lifetime of the request |
748
+ | `openai-30m` | 30 min | OpenAI's API, GPT-5.6 and later: `prompt_cache_options.ttl` is 30 minutes after the last write or reuse |
749
+ | `openai-24h` | 30 min | OpenAI's API, an earlier model with `prompt_cache_retention: '24h'` (`cache: { retention: '24h' }`): OpenAI keeps the prefix typically around 30 minutes and up to 24 hours, without promising more |
750
+ | `openai-in-memory` | 5 min | OpenAI's API, an earlier model with `in_memory` or no retention; or the ChatGPT backend of ChatGPT accounts, which takes no retention and documents none. In-memory entries last 5 to 10 minutes of inactivity |
751
+ | `configured` | `cache.warmDurationMs` | any |
752
+
753
+ With no basis (another provider or endpoint, or a custom transport that may change the request) the TTL is 5 minutes. No OpenAI retention promises a hit beyond about 30 minutes, so none makes warming unnecessary; extended retention only spaces warm turns further apart. Source: OpenAI's [prompt caching guide](https://developers.openai.com/api/docs/guides/prompt-caching), read 2026-10-11. A prompt, a steer or a running turn resets the wait, and no warm prompt is sent while a turn runs, a prompt is queued, an undo's redo history is open, a reversion is pending or a slash command runs. The warm prompt is the fixed text `FLEX_CACHE_WARM_PROMPT`, starting with `[cache-warm]`, which asks for the single word `ok`. Its turn uses the model and system of the session's last turn and offers the same tools, so it reads the cached prefix, but it refuses every tool call, takes at most two steps, caps each model call at `cacheWarm.maxOutputTokens` (default 1024) and takes no steer: `steerPrompt()` refuses one with reason `not-running`. Keeping a session warm costs a cache read of its prefix and a few new tokens per warm turn; the warm exchanges stay in the model context.
754
+
755
+ Warm turns are marked so hosts can leave them out of what they show and of what they count as work: both messages, the session `activity`, the prompt queue entry, `run.started`, `run.finished` and `model.usage` carry `cacheWarm: true`. A warm turn changes neither the session's `updatedAt` nor its `preview`, and adds no undo step. Three events report the keeper: `cache.warm.changed` (`source` `host` or `agent`, `warmUntil` absent once stopped), `cache.warm.completed` with the warm turn's `usage`, whose `cacheReadTokens` and `cacheWriteTokens` are the provider's own counts and show whether it hit the cache (OpenAI's `input_tokens_details.cached_tokens` and `cache_write_tokens`, Anthropic's `cache_read_input_tokens` and `cache_creation_input_tokens`), its `idleForMs` since the session's previous model call and its `basis`, and `cache.warm.stopped` with `reason` `deadline`, `cleared`, `failed` (the warm turn failed; `error` set) or `unavailable` (the session or its scope went away).
756
+
757
+ `builtInTools: { cacheWarm: true }` gives root sessions the `keep_cache_warm` tool, so the agent can keep its own cache warm: `{ duration: '1h' }` sets the deadline (amounts of `s`, `m` or `h`), `'off'` or `'0'` stops it. The keeper is process-local: it ends with `dispose()` and does not survive a restart. It warms only the loaded scope it was set in: retiring the scope or deleting the session stops it with `unavailable`, and it never loads a scope again. Tests pass `cacheWarm.clock` (`now`, `setTimeout`, `clearTimeout`) to drive it with a fake clock.
758
+
732
759
  ## Slash Commands
733
760
 
734
761
  `parseSlashCommand()` is the public strict pure parser. It accepts at most 768 KiB, returns `not-command` for input not starting with `/`, `malformed` for invalid slash syntax, and `parsed` with the exact input, lowercase command name, separator-stripped raw argument text, and OpenCode-compatible quoted tokenization. Command names match `[a-z][a-z0-9_-]{0,63}`. The public `isValidSlashCommandName(name)` and `isReservedSlashCommandName(name)` helpers let applications validate registration names against the same rules. Single and double quotes group tokens and are stripped; escapes are not interpreted.