@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.
Files changed (50) 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/interfaces.d.ts +89 -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 +9 -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/dist_ts_chat_harness/functions.mapping.d.ts +1 -0
  28. package/dist_ts_chat_harness/functions.mapping.js +5 -2
  29. package/package.json +3 -3
  30. package/readme.md +27 -0
  31. package/ts/00_commitinfo_data.ts +1 -1
  32. package/ts/classes.flexharness.cachewarm.ts +433 -0
  33. package/ts/classes.flexharness.callbacks.ts +1 -0
  34. package/ts/classes.flexharness.core.ts +8 -1
  35. package/ts/classes.flexharness.deletion.ts +1 -0
  36. package/ts/classes.flexharness.lifecycle.ts +3 -0
  37. package/ts/classes.flexharness.prompts.ts +25 -6
  38. package/ts/classes.flexharness.reversions.ts +7 -1
  39. package/ts/classes.flexharness.runs.ts +20 -2
  40. package/ts/classes.flexharness.ts +21 -0
  41. package/ts/interfaces.flexharness.ts +16 -0
  42. package/ts/interfaces.ts +100 -1
  43. package/ts/plugins.ts +1 -0
  44. package/ts/readme.md +27 -0
  45. package/ts/utils.harnessoptions.ts +74 -5
  46. package/ts/utils.json.ts +8 -1
  47. package/ts/utils.sessionpreview.ts +4 -4
  48. package/ts_chat_harness/classes.transcript.ts +14 -3
  49. package/ts_chat_harness/functions.mapping.ts +3 -1
  50. 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,
@@ -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.
@@ -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