@modelprofile.com/flexharness 11.1.1 → 11.3.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 (44) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.codesandbox.d.ts +58 -7
  3. package/dist_ts/classes.codesandbox.js +267 -34
  4. package/dist_ts/classes.codesandbox.worker.d.ts +6 -1
  5. package/dist_ts/classes.codesandbox.worker.js +34 -8
  6. package/dist_ts/classes.flexharness.callbacks.d.ts +6 -0
  7. package/dist_ts/classes.flexharness.callbacks.js +24 -4
  8. package/dist_ts/classes.flexharness.core.d.ts +8 -0
  9. package/dist_ts/classes.flexharness.core.js +29 -1
  10. package/dist_ts/classes.flexharness.lifecycle.js +7 -1
  11. package/dist_ts/classes.flexharness.runs.js +9 -7
  12. package/dist_ts/classes.flexharness.tooldispatch.js +2 -2
  13. package/dist_ts/interfaces.d.ts +9 -1
  14. package/dist_ts/interfaces.flexharness.d.ts +5 -3
  15. package/dist_ts/plugins.d.ts +3 -1
  16. package/dist_ts/plugins.js +2 -2
  17. package/dist_ts_chat_harness/classes.chatclient.d.ts +7 -4
  18. package/dist_ts_chat_harness/classes.chatclient.js +3 -3
  19. package/dist_ts_chat_harness/classes.transcript.d.ts +1 -0
  20. package/dist_ts_chat_harness/classes.transcript.js +16 -1
  21. package/dist_ts_chat_harness_server/classes.chathub.js +2 -1
  22. package/dist_ts_chat_harness_server/classes.chatsubscription.d.ts +6 -3
  23. package/dist_ts_chat_harness_server/classes.chatsubscription.js +18 -4
  24. package/dist_ts_chat_harness_server/interfaces.d.ts +6 -0
  25. package/package.json +3 -3
  26. package/readme.md +5 -2
  27. package/ts/00_commitinfo_data.ts +1 -1
  28. package/ts/classes.codesandbox.ts +303 -34
  29. package/ts/classes.codesandbox.worker.ts +38 -8
  30. package/ts/classes.flexharness.callbacks.ts +22 -3
  31. package/ts/classes.flexharness.core.ts +31 -0
  32. package/ts/classes.flexharness.lifecycle.ts +5 -0
  33. package/ts/classes.flexharness.runs.ts +8 -5
  34. package/ts/classes.flexharness.tooldispatch.ts +1 -1
  35. package/ts/interfaces.flexharness.ts +5 -3
  36. package/ts/interfaces.ts +10 -1
  37. package/ts/plugins.ts +3 -1
  38. package/ts/readme.md +5 -2
  39. package/ts_chat_harness/classes.chatclient.ts +9 -6
  40. package/ts_chat_harness/classes.transcript.ts +19 -0
  41. package/ts_chat_harness_server/classes.chathub.ts +1 -0
  42. package/ts_chat_harness_server/classes.chatsubscription.ts +19 -3
  43. package/ts_chat_harness_server/interfaces.ts +6 -0
  44. package/ts_chat_harness_server/readme.md +1 -1
@@ -1,8 +1,9 @@
1
1
  /**
2
- * The QuickJS side of a `run_code` program. `FlexCodeSandbox` runs every program in a worker thread of
3
- * its own with this module, and ends it by terminating the worker, which frees its WebAssembly instance
4
- * too: a native operation that never reaches QuickJS's interrupt handler cannot outlast its limits, and
5
- * a program never blocks the host's event loop.
2
+ * The QuickJS side of `run_code` programs. A worker thread of `FlexCodeSandbox` loads QuickJS once and
3
+ * runs one program at a time, each in a QuickJS runtime and context of its own, which it frees once the
4
+ * program ended by itself. A program stopped at a limit ends with its worker: terminating the worker
5
+ * frees its WebAssembly instance too, so a native operation that never reaches QuickJS's interrupt
6
+ * handler cannot outlast its limits, and a program never blocks the host's event loop.
6
7
  *
7
8
  * Written in erasable TypeScript that imports nothing of the package at run time, so the module loads
8
9
  * uncompiled as well.
@@ -29,10 +30,13 @@ export type TCodeWorkerRequest =
29
30
  /**
30
31
  * What a program's worker reports. `ready` comes once QuickJS is loaded; every `start` and `answer`
31
32
  * ends in `idle`, with the milliseconds computed so far, unless the program stopped. `done` counts the
32
- * calls the program had no answer to when it returned.
33
+ * calls the program had no answer to when it returned. `released` follows a program that ended by
34
+ * itself (`done`, or `failed` short of a limit): its runtime is freed, and the worker's WebAssembly
35
+ * memory, which never shrinks, has `memoryBytes`.
33
36
  */
34
37
  export type TCodeWorkerReport =
35
38
  | { kind: 'ready' }
39
+ | { kind: 'released'; memoryBytes: number }
36
40
  | { kind: 'call'; id: number; method: string; params: string }
37
41
  | { kind: 'log'; text: string }
38
42
  | { kind: 'idle'; cpuMs: number }
@@ -134,7 +138,8 @@ const prelude = `'use strict';
134
138
  /**
135
139
  * One program in its QuickJS runtime. It enforces the limits QuickJS can see: memory, stack, console
136
140
  * output, and computing time at every interrupt point. The host enforces the rest, and ends the worker
137
- * when the program computes past its time between two interrupt points.
141
+ * when the program computes past its time between two interrupt points. Once the program's outcome is
142
+ * reported, nothing more of it runs: work it left pending is interrupted and dropped with its runtime.
138
143
  */
139
144
  class CodeWorkerRun {
140
145
  private readonly report: (report: TCodeWorkerReport) => void;
@@ -177,6 +182,19 @@ class CodeWorkerRun {
177
182
  this.idle();
178
183
  }
179
184
 
185
+ /** The program ended by itself, so its runtime can be freed and the worker run the next program. */
186
+ public get finished(): boolean {
187
+ return this.ended && this.stopped === undefined;
188
+ }
189
+
190
+ /** Frees the program's runtime with everything in it, and the calls it left unanswered. */
191
+ public release(): void {
192
+ for (const deferred of this.deferreds.values()) deferred.dispose();
193
+ this.deferreds.clear();
194
+ this.vm.dispose();
195
+ this.runtime.dispose();
196
+ }
197
+
180
198
  /** Settles a call of the program with the host's outcome, and runs what it unblocked. */
181
199
  public answer(id: number, text: string): void {
182
200
  const deferred = this.deferreds.get(id);
@@ -233,7 +251,7 @@ class CodeWorkerRun {
233
251
  return false;
234
252
  }
235
253
  result.value.dispose();
236
- return true;
254
+ return !this.ended;
237
255
  }
238
256
 
239
257
  /** Runs the program's pending jobs after a call settled or the program started. */
@@ -259,6 +277,11 @@ class CodeWorkerRun {
259
277
  }
260
278
 
261
279
  private failWith(handle: plugins.IQuickJsHandle, phase: TCodeWorkerPhase): void {
280
+ if (this.ended) {
281
+ // The interruption of work the program left pending once its outcome was out.
282
+ handle.dispose();
283
+ return;
284
+ }
262
285
  let dumped: unknown;
263
286
  // Reading the thrown value can run program code, such as a toJSON: it counts as computing.
264
287
  this.enter();
@@ -268,6 +291,7 @@ class CodeWorkerRun {
268
291
  dumped = undefined;
269
292
  } finally {
270
293
  this.leave();
294
+ handle.dispose();
271
295
  }
272
296
  if (this.stopped) {
273
297
  this.stop(this.stopped);
@@ -313,7 +337,7 @@ class CodeWorkerRun {
313
337
  }
314
338
 
315
339
  private interrupted(): boolean {
316
- if (this.stopped) return true;
340
+ if (this.stopped || this.ended) return true;
317
341
  if (this.enteredAt !== undefined && this.cpuMs + performance.now() - this.enteredAt > this.limits.cpuMs) {
318
342
  this.stopped = 'cpu';
319
343
  return true;
@@ -340,6 +364,12 @@ if (port) {
340
364
  } else {
341
365
  run?.answer(request.id, request.text);
342
366
  }
367
+ // A failure to free the runtime throws: the worker fails, and the host ends it.
368
+ if (run?.finished) {
369
+ run.release();
370
+ run = undefined;
371
+ report({ kind: 'released', memoryBytes: module.getWasmMemory().buffer.byteLength });
372
+ }
343
373
  });
344
374
  report({ kind: 'ready' });
345
375
  }
@@ -39,7 +39,7 @@ export class FlexHarnessRunCallbacks<TScope> {
39
39
  const run = this.callbackRun(state, sessionId);
40
40
  if (!run || !delta) return;
41
41
  const lastPart = run.callbackParts.at(-1);
42
- const openPart = lastPart?.type === 'text' && !run.completedTextPartIds.has(lastPart.partId)
42
+ const openPart = lastPart?.type === 'text' && run.stepPartIds.has(lastPart.partId)
43
43
  ? lastPart
44
44
  : undefined;
45
45
  const created = !openPart;
@@ -54,6 +54,7 @@ export class FlexHarnessRunCallbacks<TScope> {
54
54
  )) return;
55
55
  if (created) {
56
56
  run.callbackParts.push(part);
57
+ run.stepPartIds.add(part.partId);
57
58
  run.partTextUtf8Bytes.set(part.partId, 0);
58
59
  this.core.emitPartEvent(run, 'part.started', part);
59
60
  }
@@ -75,6 +76,7 @@ export class FlexHarnessRunCallbacks<TScope> {
75
76
  };
76
77
  run.reasoningPartIds.set(id, part.partId);
77
78
  run.callbackParts.push(part);
79
+ run.stepPartIds.add(part.partId);
78
80
  run.partTextUtf8Bytes.set(part.partId, 0);
79
81
  this.core.emitPartEvent(run, 'part.started', part);
80
82
  }
@@ -115,6 +117,9 @@ export class FlexHarnessRunCallbacks<TScope> {
115
117
  }
116
118
  part.status = 'completed';
117
119
  part.completedAt = new Date().toISOString();
120
+ // Model calls number their reasoning summaries afresh, so a later call's summary with the
121
+ // same ID is another one.
122
+ run.reasoningPartIds.delete(id);
118
123
  this.core.emitPartEvent(run, 'part.completed', part);
119
124
  }
120
125
 
@@ -128,10 +133,10 @@ export class FlexHarnessRunCallbacks<TScope> {
128
133
  const run = this.callbackRun(state, sessionId);
129
134
  if (!run) return;
130
135
  for (const part of run.callbackParts) {
131
- if (part.type !== 'text' || run.completedTextPartIds.has(part.partId)) continue;
132
- run.completedTextPartIds.add(part.partId);
136
+ if (part.type !== 'text' || !run.stepPartIds.has(part.partId)) continue;
133
137
  this.core.emitPartEvent(run, 'part.completed', part);
134
138
  }
139
+ run.stepPartIds.clear();
135
140
  }
136
141
 
137
142
  private textAppendAccounting(
@@ -372,9 +377,23 @@ export class FlexHarnessRunCallbacks<TScope> {
372
377
  }
373
378
  }
374
379
 
380
+ /**
381
+ * Announces a retry. The failed model call's text and reasoning are not in the session, so their
382
+ * parts are removed first, last part first, each with a `part.removed` event; its tool calls
383
+ * stay. The removals are outside the callback limits, like completions: their number is bounded
384
+ * by the run's parts. What the removed parts took of the limits stays taken.
385
+ */
375
386
  public onRetry(state: IStorageState, sessionId: string, retry: plugins.IAgentRetryEvent): void {
376
387
  const run = this.callbackRun(state, sessionId);
377
388
  if (!run || !this.reserveCallbackCapacity(run, 1, 0, 0)) return;
389
+ for (let partIndex = run.callbackParts.length - 1; partIndex >= 0; partIndex--) {
390
+ const part = run.callbackParts[partIndex]!;
391
+ if (!run.stepPartIds.has(part.partId)) continue;
392
+ this.core.emitPartRemovedEvent(run, part);
393
+ run.callbackParts.splice(partIndex, 1);
394
+ run.partTextUtf8Bytes.delete(part.partId);
395
+ }
396
+ run.stepPartIds.clear();
378
397
  this.core.emitEvent(run.scopeId, run.stored.session, {
379
398
  type: 'run.retrying',
380
399
  runId: run.runId,
@@ -43,6 +43,7 @@ import type {
43
43
  TFlexReversionPolicy,
44
44
  } from './interfaces.js';
45
45
  import { InMemoryFlexHarnessStores } from './classes.stores.js';
46
+ import { FlexCodeSandbox } from './classes.codesandbox.js';
46
47
  import {
47
48
  assertJsonSerializable,
48
49
  cloneSerializable,
@@ -172,6 +173,7 @@ export class FlexHarnessCore<TScope> {
172
173
  public readonly trustedInternalErrors = new WeakSet<object>();
173
174
  public sequence = 0;
174
175
  public closed = false;
176
+ private codeSandbox?: Promise<FlexCodeSandbox>;
175
177
  public readonly results = new FlexHarnessResults<TScope>(this);
176
178
  public readonly toolDispatch = new FlexHarnessToolDispatch<TScope>(this);
177
179
  public readonly providers = new FlexHarnessToolProviders<TScope>(this);
@@ -1716,6 +1718,19 @@ export class FlexHarnessCore<TScope> {
1716
1718
  });
1717
1719
  }
1718
1720
 
1721
+ /** Announces that `part` leaves the run's parts; call it while the part is still among them. */
1722
+ public emitPartRemovedEvent(run: IActiveRun, part: TFlexMessagePart): void {
1723
+ const { messageIndex, partIndex } = this.partEventCoordinates(run, part.partId);
1724
+ this.emitEvent(run.scopeId, run.stored.session, {
1725
+ type: 'part.removed',
1726
+ runId: run.runId,
1727
+ messageId: run.assistantMessageId,
1728
+ messageIndex,
1729
+ partId: part.partId,
1730
+ partIndex,
1731
+ });
1732
+ }
1733
+
1719
1734
  private partEventCoordinates(
1720
1735
  run: IActiveRun,
1721
1736
  partId: string,
@@ -1833,4 +1848,20 @@ export class FlexHarnessCore<TScope> {
1833
1848
  public assertOpen(): void {
1834
1849
  if (this.closed) throw new FlexHarnessClosedError();
1835
1850
  }
1851
+
1852
+ /** The harness's code mode sandbox, loaded with its first code mode run; its workers are the harness's. */
1853
+ public loadCodeSandbox(): Promise<FlexCodeSandbox> {
1854
+ this.assertOpen();
1855
+ this.codeSandbox ??= FlexCodeSandbox.load().catch((error: unknown) => {
1856
+ this.codeSandbox = undefined;
1857
+ throw error;
1858
+ });
1859
+ return this.codeSandbox;
1860
+ }
1861
+
1862
+ /** Stops the code mode programs still running and ends every worker of the sandbox. */
1863
+ public async disposeCodeSandbox(): Promise<void> {
1864
+ const sandbox = await this.codeSandbox?.catch(() => undefined);
1865
+ await sandbox?.dispose();
1866
+ }
1836
1867
  }
@@ -771,6 +771,11 @@ export class FlexHarnessLifecycle<TScope> {
771
771
  errors.push(...await this.closeOrphanedResources(storageKey));
772
772
  }
773
773
  }
774
+ try {
775
+ await this.core.disposeCodeSandbox();
776
+ } catch (error) {
777
+ this.core.appendUnexpectedErrors(errors, error);
778
+ }
774
779
  this.core.listeners.clear();
775
780
  if (errors.length > 0) throw combineErrors(errors);
776
781
  this.core.compaction.compactorInvocationContext.disable();
@@ -26,7 +26,6 @@ import {
26
26
  type TFlexMessagePart,
27
27
  } from './interfaces.js';
28
28
  import { FlexToolCatalog, assertNoCatalogCollisions } from './classes.toolcatalog.js';
29
- import { FlexCodeSandbox } from './classes.codesandbox.js';
30
29
  import { cloneSerializable } from './utils.json.js';
31
30
  import type { INormalizedFlexPrompt } from './utils.prompt.js';
32
31
  import {
@@ -119,7 +118,7 @@ export class FlexHarnessRuns<TScope> {
119
118
  cacheWriteTokens: 0,
120
119
  },
121
120
  reasoningPartIds: new Map(),
122
- completedTextPartIds: new Set(),
121
+ stepPartIds: new Set(),
123
122
  toolPartIds: new Map(),
124
123
  trustedToolExecutionErrors: new Map(),
125
124
  unconfirmedToolCalls: new Map(),
@@ -495,7 +494,11 @@ export class FlexHarnessRuns<TScope> {
495
494
  catalog = await FlexToolCatalog.create(toolHandle.catalog, this.core.toolCatalogOptions, {
496
495
  results: this.core.stores.results !== undefined,
497
496
  });
498
- if (catalog.codeMode) run.codeSandbox = await FlexCodeSandbox.load();
497
+ if (catalog.codeMode) {
498
+ run.codeSandbox = await this.core.loadCodeSandbox();
499
+ // The worker loads QuickJS while the model writes the program.
500
+ run.codeSandbox.prewarm(run.scopeId);
501
+ }
499
502
  } catch (error) {
500
503
  throw this.core.projectExternalError(run, error, 'toolProvider');
501
504
  }
@@ -811,7 +814,7 @@ export class FlexHarnessRuns<TScope> {
811
814
  run.reservedAssistantMessage = publicSnapshot(nextAssistant);
812
815
  run.callbackParts = [];
813
816
  run.reasoningPartIds.clear();
814
- run.completedTextPartIds.clear();
817
+ run.stepPartIds.clear();
815
818
  this.core.emitEvent(run.scopeId, run.stored.session, {
816
819
  type: 'message.updated',
817
820
  runId: run.runId,
@@ -1030,7 +1033,7 @@ export class FlexHarnessRuns<TScope> {
1030
1033
  for (const part of terminal.assistantMessage.parts) {
1031
1034
  const callbackPart = run.callbackParts.find((entry) => entry.partId === part.partId);
1032
1035
  const shouldComplete = part.type === 'text'
1033
- ? callbackPart?.type === 'text' && !run.completedTextPartIds.has(part.partId)
1036
+ ? callbackPart?.type === 'text' && run.stepPartIds.has(part.partId)
1034
1037
  : part.type === 'reasoning'
1035
1038
  ? callbackPart?.type === 'reasoning' && callbackPart.status === 'running'
1036
1039
  : part.type === 'tool'
@@ -475,7 +475,7 @@ export class FlexHarnessToolDispatch<TScope> {
475
475
  }
476
476
  },
477
477
  };
478
- const result = await run.codeSandbox!.run(code, host, catalog.codeMode!, abortSignal);
478
+ const result = await run.codeSandbox!.run(code, host, catalog.codeMode!, abortSignal, run.scopeId);
479
479
  if (!result.ok) {
480
480
  throw this.catalogCallError(run, toolCallId, normalizeJsonValue(result.failure, this.core.toolOutputLimits));
481
481
  }
@@ -223,12 +223,14 @@ export interface IActiveRun {
223
223
  /** The usage the provider reported for the run's model calls so far. */
224
224
  usage: IFlexUsage;
225
225
  metricsOperation?: ISessionMetricsOperation;
226
+ /** The part of each reasoning summary in progress, by the model's reasoning ID. */
226
227
  reasoningPartIds: Map<string, string>;
227
228
  /**
228
- * The text parts whose model step is committed. Each emitted its `part.completed`; text that
229
- * follows starts a new part.
229
+ * The text and reasoning parts the model step in progress streamed. When the step commits, its
230
+ * text parts complete and text that follows starts a new part; when its model call is retried,
231
+ * they are removed.
230
232
  */
231
- completedTextPartIds: Set<string>;
233
+ stepPartIds: Set<string>;
232
234
  toolPartIds: Map<string, string>;
233
235
  trustedToolExecutionErrors: Map<string, Error>;
234
236
  /**
package/ts/interfaces.ts CHANGED
@@ -1962,7 +1962,16 @@ export interface IFlexPartDeltaEvent extends IFlexPartEventBase {
1962
1962
  readonly textUtf8Bytes: number;
1963
1963
  }
1964
1964
 
1965
- export type TFlexPartEvent = IFlexPartSnapshotEvent | IFlexPartDeltaEvent;
1965
+ /**
1966
+ * A streamed part left its message: the model call that streamed it failed and is retried, and
1967
+ * the retry streams its own parts. `partIndex` is the part's position before it left; the parts
1968
+ * after it move up by one.
1969
+ */
1970
+ export interface IFlexPartRemovedEvent extends IFlexPartEventBase {
1971
+ readonly type: 'part.removed';
1972
+ }
1973
+
1974
+ export type TFlexPartEvent = IFlexPartSnapshotEvent | IFlexPartDeltaEvent | IFlexPartRemovedEvent;
1966
1975
 
1967
1976
  export interface IFlexPermissionRequestedEvent extends IFlexEventBase {
1968
1977
  readonly type: 'permission.requested';
package/ts/plugins.ts CHANGED
@@ -61,6 +61,8 @@ export interface IQuickJsRuntime {
61
61
 
62
62
  export interface IQuickJsModule {
63
63
  newRuntime(): IQuickJsRuntime;
64
+ /** The module's WebAssembly memory, shared by all its runtimes; it grows and never shrinks. */
65
+ getWasmMemory(): WebAssembly.Memory;
64
66
  }
65
67
 
66
68
  /** A failed strip, as amaro reports it. */
@@ -76,7 +78,7 @@ export interface ICodeSandboxEngine {
76
78
  }
77
79
 
78
80
  /**
79
- * Loads amaro, and finds QuickJS: the optional peers code mode needs. Every program's worker loads
81
+ * Loads amaro, and finds QuickJS: the optional peers code mode needs. Every code mode worker loads
80
82
  * QuickJS itself.
81
83
  */
82
84
  export const importCodeSandboxEngine = async (): Promise<ICodeSandboxEngine> => {
package/ts/readme.md CHANGED
@@ -1065,6 +1065,9 @@ const unsubscribe = harness.subscribe((event) => {
1065
1065
  event.textUtf8Bytes,
1066
1066
  );
1067
1067
  break;
1068
+ case 'part.removed':
1069
+ removePart(event.sessionId, event.messageIndex, event.partIndex);
1070
+ break;
1068
1071
  case 'permission.requested':
1069
1072
  showPermission(event.request);
1070
1073
  break;
@@ -1100,13 +1103,13 @@ const unsubscribe = harness.subscribe((event) => {
1100
1103
  unsubscribe();
1101
1104
  ```
1102
1105
 
1103
- Events are discriminated, deeply immutable values with one global sequence within each `FlexHarness` instance. Every `IFlexEventBase` carries the exact source `sessionGenerationId` and `sessionGenerationSequence`; `session.deleted` retains the deleted generation after live metadata is removed. Listener exceptions are isolated from runs and other listeners. Every accepted queue entry emits `prompt.queued` and exactly one `prompt.finished`. Every accepted steer emits `prompt.steer.accepted`, and `prompt.steer.applied`, after the `message.*` events of its user message, once the run took it in; `run.finished` lists it in `unappliedSteerIds` unless it is in the model context of later turns. Durable promotion additionally emits `prompt.started`, and actual model preparation emits `prompt.running`; cancellation or failure can omit either intermediate event. Existing durable run/message terminal events precede `prompt.finished`. Every callback-backed streamed text part emits exactly one `part.completed` event before the corresponding `run.finished` event: once the model step that streamed it is committed, so its text is final and saved, or with the run's terminal projection when that step did not commit. Text a later model step streams starts a new part. Every `part.started`, `part.delta`, `part.updated`, and `part.completed` event carries zero-based `messageIndex` and `partIndex` coordinates from the session's authoritative message and part sequences.
1106
+ Events are discriminated, deeply immutable values with one global sequence within each `FlexHarness` instance. Every `IFlexEventBase` carries the exact source `sessionGenerationId` and `sessionGenerationSequence`; `session.deleted` retains the deleted generation after live metadata is removed. Listener exceptions are isolated from runs and other listeners. Every accepted queue entry emits `prompt.queued` and exactly one `prompt.finished`. Every accepted steer emits `prompt.steer.accepted`, and `prompt.steer.applied`, after the `message.*` events of its user message, once the run took it in; `run.finished` lists it in `unappliedSteerIds` unless it is in the model context of later turns. Durable promotion additionally emits `prompt.started`, and actual model preparation emits `prompt.running`; cancellation or failure can omit either intermediate event. Existing durable run/message terminal events precede `prompt.finished`. Every callback-backed streamed text part that stays in its message emits exactly one `part.completed` event before the corresponding `run.finished` event: once the model step that streamed it is committed, so its text is final and saved, or with the run's terminal projection when that step did not commit. Text a later model step streams starts a new part. When a model call fails and is retried, the text and reasoning parts it streamed are not in the session: `part.removed` takes each of them out of its message, last part first, and the retry streams its own parts. Its `partIndex` is the part's position before it left; the parts after it move up by one. A removed part has no further events, and `part.removed` does not count against `callbackLimits`. The tool parts of calls the failed model call executed stay. Every `part.started`, `part.delta`, `part.updated`, `part.completed`, and `part.removed` event carries zero-based `messageIndex` and `partIndex` coordinates from the session's authoritative message and part sequences.
1104
1107
 
1105
1108
  `getSessionView(scopeId, sessionId)` reads a session's metadata, visible messages and pending permission requests in one step, together with the instance's event `sequence` they include. The messages include what a running turn has streamed so far, so a consumer that applies every event of the instance with a greater `sequence` follows the session exactly from the view on; events up to `sequence` are already in it. The read waits while a queued write or a change that emits events for the session is in progress; a session that keeps changing through 64 consecutive waits is refused with `FlexHarnessSessionBusyError`, and the read can be repeated. Session metadata is current: status changes such as `waiting_permission` emit no event. A permission request its run leaves unanswered leaves `pendingPermissions` without an event of its own; the run's `run.finished` follows.
1106
1109
 
1107
1110
  `part.started`, `part.updated`, and `part.completed` are snapshot events with a complete immutable `part`. Tool and reasoning parts carry `startedAt` (ISO time the call or reasoning began) and, once they reach a terminal status, `completedAt`; parts recorded by earlier versions have neither, so a consumer falls back to the message times for them. A newly streamed text part emits `part.started` with empty text before its first delta; reasoning parts also start empty. `part.delta` is a separate delta-only event: it has no cumulative `part`, and carries `partType`, the required exact source `delta`, and `baseTextUtf8Bytes`/`textUtf8Bytes` for the cumulative text before and after that delta. The counters remain correct when a UTF-16 surrogate pair is split across callbacks. Exact deltas are never truncated, including a single delta above the 96 KiB message-transfer text limit. `part.updated` remains a cumulative replacement snapshot for running tool output or metadata, not a text delta. `session.history.changed` carries `direction: 'undo' | 'redo' | 'branch'`; `runId` is present for undo and redo. Events contain public IDs, exact delta payloads, and public snapshots only; they do not expose prompt payloads, the resolved scope object, storage key, model object, provider options, or raw storage key.
1108
1111
 
1109
- Part events narrow through the exported `TFlexPartEvent` union. `IFlexPartEventBase` contains their shared coordinates, `IFlexPartSnapshotEvent` owns snapshot events and their complete `part`, and `IFlexPartDeltaEvent` owns exact delta-only events and their UTF-8 counters.
1112
+ Part events narrow through the exported `TFlexPartEvent` union. `IFlexPartEventBase` contains their shared coordinates, `IFlexPartSnapshotEvent` owns snapshot events and their complete `part`, `IFlexPartDeltaEvent` owns exact delta-only events and their UTF-8 counters, and `IFlexPartRemovedEvent` is `part.removed`, which carries only the coordinates.
1110
1113
 
1111
1114
  ### Migrating Part Events to 5.x
1112
1115
 
@@ -44,10 +44,13 @@ export interface IFlexHarnessChatClientListener {
44
44
  permissions?(permissions: plugins.IHarnessPermissionRequest[]): void;
45
45
  history?(history: IFlexHarnessChatHistoryState): void;
46
46
  steer?(outcome: IFlexHarnessChatSteerOutcome): void;
47
- /** the open conversation could not be followed again and was closed; the message says why */
48
- closed?(message: string): void;
49
- /** attaching again after a reconnect failed; the message says why */
50
- error?(message: string): void;
47
+ /**
48
+ * the open conversation could not be followed again and was closed; the message says why, and
49
+ * the error is what the transport failed with, for a view that words its own refusals
50
+ */
51
+ closed?(message: string, error: unknown): void;
52
+ /** attaching again after a reconnect failed; the message says why, and the error is the transport's */
53
+ error?(message: string, error: unknown): void;
51
54
  }
52
55
 
53
56
  /** Runs one tool of a turn in this tab and answers with what the model reads. */
@@ -104,7 +107,7 @@ export class FlexHarnessChatClient {
104
107
  });
105
108
  this.stopWatchingReconnects = this.transport.onReconnect(() => {
106
109
  void this.attach().catch((error) =>
107
- this.listener?.error?.(errorText(error, 'The conversation could not be reattached after a reconnect.')),
110
+ this.listener?.error?.(errorText(error, 'The conversation could not be reattached after a reconnect.'), error),
108
111
  );
109
112
  });
110
113
  }
@@ -267,7 +270,7 @@ export class FlexHarnessChatClient {
267
270
  // another conversation was opened or a new one started meanwhile: that one stays
268
271
  if (this.openEpoch !== epoch) return;
269
272
  this.clear();
270
- this.listener?.closed?.(errorText(error, 'The conversation could not be opened again.'));
273
+ this.listener?.closed?.(errorText(error, 'The conversation could not be opened again.'), error);
271
274
  }
272
275
  }
273
276
 
@@ -250,6 +250,9 @@ export class FlexHarnessTranscript {
250
250
  case 'part.delta':
251
251
  this.applyPartDelta(change, event);
252
252
  break;
253
+ case 'part.removed':
254
+ this.removePart(change, event);
255
+ break;
253
256
  case 'permission.requested':
254
257
  this.addPermission(event.request);
255
258
  change.permissions = true;
@@ -396,4 +399,20 @@ export class FlexHarnessTranscript {
396
399
  ? { type: 'text', messageId: rowId, delta: event.delta }
397
400
  : { type: 'reasoning', messageId: rowId, partId: event.partId, delta: event.delta });
398
401
  }
402
+
403
+ private removePart(
404
+ change: IFlexHarnessTranscriptChange,
405
+ event: Extract<TFlexHarnessEvent, { type: 'part.removed' }>,
406
+ ): void {
407
+ const message = this.flexMessages.find((entry) => entry.messageId === event.messageId);
408
+ if (!message || message.parts[event.partIndex]?.partId !== event.partId) {
409
+ change.reload = true;
410
+ return;
411
+ }
412
+ message.parts.splice(event.partIndex, 1);
413
+ const key = flexPartTimingKey(event.messageId, event.partId);
414
+ this.timings.delete(key);
415
+ this.partBytes.delete(key);
416
+ change.messages = true;
417
+ }
399
418
  }
@@ -38,6 +38,7 @@ export const defaultFlexHarnessChatLimits: Readonly<IFlexHarnessChatLimits> = Ob
38
38
  admissionWaitMs: 30 * 1000,
39
39
  toolTimeoutMs: 2 * 60 * 1000,
40
40
  maxPendingDeltas: 2_000,
41
+ minUpdateIntervalMs: 100,
41
42
  maxMessageChars: 100_000,
42
43
  maxAttachments: 16,
43
44
  maxAttachmentChars: 8 * 1024 * 1024,
@@ -26,6 +26,12 @@ const emptyPending = (): IPendingUpdate => ({
26
26
  steers: [],
27
27
  });
28
28
 
29
+ /** Resolves after the time; its timer keeps no process alive. */
30
+ const delay = (msArg: number): Promise<void> =>
31
+ new Promise<void>((resolve) => {
32
+ setTimeout(resolve, msArg).unref?.();
33
+ });
34
+
29
35
  const hasPending = (pendingArg: IPendingUpdate): boolean =>
30
36
  pendingArg.reset
31
37
  || pendingArg.rows
@@ -58,8 +64,9 @@ export interface IFlexHarnessChatSubscriptionOptions {
58
64
 
59
65
  /**
60
66
  * One tab following one conversation. A `FlexHarnessTranscript` takes every event of the harness
61
- * instance; what changed reaches the tab as numbered updates, one at a time. A transcript that
62
- * loses track, or an update the tab does not acknowledge, makes the next update a `reset`.
67
+ * instance; what changed reaches the tab as numbered updates, one at a time and at most one per
68
+ * `minUpdateIntervalMs`. A transcript that loses track, or an update the tab does not acknowledge,
69
+ * makes the next update a `reset`.
63
70
  */
64
71
  export class FlexHarnessChatSubscription {
65
72
  public readonly id: string;
@@ -80,6 +87,8 @@ export class FlexHarnessChatSubscription {
80
87
  private sentRows = new Map<string, string>();
81
88
  private pending: IPendingUpdate = emptyPending();
82
89
  private sending = false;
90
+ /** when the last update was taken to be sent, by `performance.now()` */
91
+ private lastUpdateAt = Number.NEGATIVE_INFINITY;
83
92
  private closed = false;
84
93
 
85
94
  constructor(optionsArg: IFlexHarnessChatSubscriptionOptions) {
@@ -176,13 +185,19 @@ export class FlexHarnessChatSubscription {
176
185
  }
177
186
  }
178
187
 
179
- /** Sends what is pending, one update at a time, in seq order. */
188
+ /** Sends what is pending, one update at a time, in seq order, `minUpdateIntervalMs` apart. */
180
189
  private async pump(): Promise<void> {
181
190
  if (this.sending) return;
182
191
  this.sending = true;
183
192
  let inFlight: TUpdate | null = null;
184
193
  try {
185
194
  while (!this.closed && this.state === 'live' && hasPending(this.pending)) {
195
+ const wait = this.lastUpdateAt + this.limits.minUpdateIntervalMs - performance.now();
196
+ if (wait > 0) {
197
+ // what changes meanwhile goes into this update
198
+ await delay(wait);
199
+ continue;
200
+ }
186
201
  const peer = await this.peers.find(this.tab);
187
202
  if (!peer) {
188
203
  this.closeSelf();
@@ -190,6 +205,7 @@ export class FlexHarnessChatSubscription {
190
205
  }
191
206
  if (this.closed || this.state !== 'live') return;
192
207
  inFlight = await this.takeUpdate();
208
+ this.lastUpdateAt = performance.now();
193
209
  await peer.update(inFlight);
194
210
  inFlight = null;
195
211
  }
@@ -75,6 +75,12 @@ export interface IFlexHarnessChatLimits {
75
75
  toolTimeoutMs: number;
76
76
  /** Deltas one update may carry; a tab further behind gets the changed rows. Default: 2000. */
77
77
  maxPendingDeltas: number;
78
+ /**
79
+ * The least time between two updates of one subscription; what changes meanwhile goes into the
80
+ * next. It bounds the requests a streaming turn costs a transport that answers every update with
81
+ * one, as TypedSocket pulls do. Default: 100 ms.
82
+ */
83
+ minUpdateIntervalMs: number;
78
84
  /** Characters of a message's text. Default: 100 000. */
79
85
  maxMessageChars: number;
80
86
  /** Attachments of one message. Default: 16. */
@@ -47,7 +47,7 @@ The host keeps its harness, models, system prompt, tools and scopes. It authenti
47
47
  ## Conversations
48
48
 
49
49
  - `attach(caller)` lists the tab's conversations, newest first, and forgets what the tab followed. A tab attaches after loading and after every reconnect; conversations created, renamed or deleted afterwards reach it as list pushes (`IFlexHarnessChatPeer.sessions`, an `IFlexHarnessChatSessionsPush`).
50
- - `open(caller, { sessionId, subscriptionId })` follows a conversation. Its state reaches the tab as numbered `IFlexHarnessChatUpdate` pushes, starting with a `reset`: changed rows, streamed deltas, status, usage, pending permissions, undo and redo availability, and what became of steers. A tab follows one conversation at a time. An update the tab does not acknowledge makes the next change a `reset`; a tab that sees a gap in `seq` opens the conversation again.
50
+ - `open(caller, { sessionId, subscriptionId })` follows a conversation. Its state reaches the tab as numbered `IFlexHarnessChatUpdate` pushes, starting with a `reset`: changed rows, streamed deltas, status, usage, pending permissions, undo and redo availability, and what became of steers. Updates go one at a time and at most one per `limits.minUpdateIntervalMs` (100 ms by default); what changes meanwhile goes into the next. A transport that answers every update with a request, as TypedSocket pulls do, so makes at most one request per interval for a streaming turn. A tab follows one conversation at a time. An update the tab does not acknowledge makes the next change a `reset`; a tab that sees a gap in `seq` opens the conversation again.
51
51
  - `send(caller, { sessionId?, send, context })` starts a conversation without `sessionId`. While a turn runs, the message steers it; one the turn no longer takes waits up to `admissionWaitMs` for the ending turn and starts the next one. `readContext` checks the context the message is sent with; the turn carries it. The composer's model, account and effort choices are not used: the host's model resolver decides from the context.
52
52
  - `abort`, `respondToPermission`, `moveHistory` (FlexHarness undo and redo), `renameSession` and `deleteSession` act on a conversation the caller reaches. An imported conversation can be read, not continued.
53
53