@modelprofile.com/flexharness-agent 9.9.1 → 10.0.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.
@@ -20,8 +20,13 @@ import {
20
20
  type IUserMessageEvent,
21
21
  type TAgentEvent,
22
22
  type TAgentGenerationOutcome,
23
+ type TAgentToolCallPart,
23
24
  type TAgentToolResultOutput,
24
25
  } from './smartagent.events.js';
26
+ import type {
27
+ AgentPendingInputQueue,
28
+ IAgentAppliedInput,
29
+ } from './smartagent.pendinginput.js';
25
30
  import {
26
31
  AgentGenerationLeaseCleanupError,
27
32
  ContextOverflowError,
@@ -31,6 +36,7 @@ import {
31
36
  type IAgentGenerationLease,
32
37
  type IAgentCompactOptions,
33
38
  type IAgentBeginGenerationOptions,
39
+ type IAgentFinalizeGenerationOptions,
34
40
  type IAgentSession,
35
41
  type IAgentSessionAbortOptions,
36
42
  type IAgentSessionChange,
@@ -101,8 +107,11 @@ interface IScheduledGeneration {
101
107
  cache?: TAgentCacheSetting;
102
108
  maxSteps?: number;
103
109
  abort?: AbortSignal;
110
+ stop?: AbortSignal;
104
111
  transaction?: IAgentGenerationHandle;
105
112
  prepare?: TAgentGenerationPrepare;
113
+ pendingInput?: AgentPendingInputQueue;
114
+ onInputApplied?: (input: IAgentAppliedInput) => void | Promise<void>;
106
115
  timer: NodeJS.Timeout;
107
116
  controller: AbortController;
108
117
  removeAbortListener: () => void;
@@ -316,6 +325,7 @@ export class AgentSessionEngine implements IAgentSession {
316
325
  private sequence = 0;
317
326
  private persistenceRevision = 0;
318
327
  private persistenceQueue: Promise<void> = Promise.resolve();
328
+ private pendingPersistence?: { snapshot: TAgentEvent[]; task: Promise<void> };
319
329
  private persistenceError: unknown;
320
330
  private readonly notificationDispatches = new Set<Promise<void>>();
321
331
  private readonly listeners = new Map<TAgentSessionChangeListener, IAgentSessionListenerState>();
@@ -401,6 +411,14 @@ export class AgentSessionEngine implements IAgentSession {
401
411
  if (!Number.isSafeInteger(maxContextOverflowRetries) || maxContextOverflowRetries < 0) {
402
412
  throw new Error('maxContextOverflowRetries must be a non-negative integer.');
403
413
  }
414
+ if (options.contextCompactionBytes !== undefined && options.contextCompactionBytes !== false
415
+ && (!Number.isSafeInteger(options.contextCompactionBytes)
416
+ || options.contextCompactionBytes < 1 || options.contextCompactionBytes > 32 * 1024 * 1024)) {
417
+ throw new Error('contextCompactionBytes must be false or an integer from 1 through 33554432.');
418
+ }
419
+ if (typeof options.contextCompactionBytes === 'number' && !options.contextCompactor) {
420
+ throw new Error('contextCompactionBytes requires a contextCompactor.');
421
+ }
404
422
  const changeListenerTimeoutMs = options.changeListenerTimeoutMs ?? 30_000;
405
423
  if (!Number.isSafeInteger(changeListenerTimeoutMs) || changeListenerTimeoutMs <= 0) {
406
424
  throw new Error('changeListenerTimeoutMs must be a positive integer.');
@@ -500,14 +518,24 @@ export class AgentSessionEngine implements IAgentSession {
500
518
  private queuePersistence(): Promise<void> {
501
519
  if (!this.options.eventStore) return Promise.resolve();
502
520
  const snapshot = structuredClone(this.events);
521
+ if (this.pendingPersistence) {
522
+ this.pendingPersistence.snapshot = snapshot;
523
+ return this.pendingPersistence.task;
524
+ }
525
+ let pending: NonNullable<AgentSessionEngine['pendingPersistence']>;
503
526
  const operation = this.persistenceQueue.then(async () => {
527
+ if (this.pendingPersistence === pending) this.pendingPersistence = undefined;
504
528
  if (this.persistenceError !== undefined) throw this.persistenceError;
505
529
  this.persistenceRevision = await this.options.eventStore!.save(
506
530
  this.options.sessionId!,
507
- snapshot,
531
+ pending.snapshot,
508
532
  this.persistenceRevision,
509
533
  );
510
534
  });
535
+ // One durable snapshot covers every commit queued before this write begins. In-flight snapshots
536
+ // stay immutable, and callers still wait for the write that contains their event.
537
+ pending = { snapshot, task: operation };
538
+ this.pendingPersistence = pending;
511
539
  this.persistenceQueue = operation.catch((error) => {
512
540
  this.persistenceError ??= error;
513
541
  });
@@ -821,6 +849,7 @@ export class AgentSessionEngine implements IAgentSession {
821
849
  generationId: string,
822
850
  outcome: TAgentGenerationOutcome,
823
851
  reason?: string,
852
+ stopped = false,
824
853
  ): Promise<void> {
825
854
  const transaction = this.transaction(generationId);
826
855
  if (
@@ -853,6 +882,7 @@ export class AgentSessionEngine implements IAgentSession {
853
882
  generationId,
854
883
  outcome,
855
884
  reason,
885
+ ...(stopped ? { stopped: true } : {}),
856
886
  parentIds: [parent.id],
857
887
  }));
858
888
  await this.flushPersistence();
@@ -874,6 +904,7 @@ export class AgentSessionEngine implements IAgentSession {
874
904
  public finalizeGeneration(
875
905
  transaction: IAgentGenerationHandle,
876
906
  outcome: TAgentGenerationOutcome,
907
+ options: IAgentFinalizeGenerationOptions = {},
877
908
  ): Promise<void> {
878
909
  const capabilityError = this.transactionalStoreCapabilityError();
879
910
  if (capabilityError) return Promise.reject(capabilityError);
@@ -881,6 +912,9 @@ export class AgentSessionEngine implements IAgentSession {
881
912
  if (!['accepted', 'rejected', 'interrupted'].includes(outcome)) {
882
913
  throw new Error(`Invalid transactional generation outcome "${String(outcome)}".`);
883
914
  }
915
+ if (options.stopped === true && outcome !== 'interrupted') {
916
+ throw new Error('Only an interrupted transactional generation can be stopped.');
917
+ }
884
918
  const state = this.verifyGenerationClaim(transaction);
885
919
  if (!('begun' in state)) {
886
920
  if (state.outcome === outcome) return;
@@ -888,7 +922,24 @@ export class AgentSessionEngine implements IAgentSession {
888
922
  `Transactional generation "${transaction.generationId}" already has outcome "${state.outcome}".`,
889
923
  );
890
924
  }
891
- await this.appendGenerationOutcome(transaction.generationId, outcome);
925
+ await this.appendGenerationOutcome(
926
+ transaction.generationId,
927
+ outcome,
928
+ undefined,
929
+ options.stopped === true,
930
+ );
931
+ if (this.options.contextCompactor && this.options.contextCompactionBytes !== false
932
+ && this.options.eventStore?.archive
933
+ && getOpenAgentGenerationTransactions(this.events).length === 0
934
+ && getUncertainToolExecutionIntents(this.events).length === 0) {
935
+ const activeIds = new Set(this.events.map((event) => event.id));
936
+ for (const event of [...this.events]) {
937
+ if (event.type === 'context-compaction' && event.automatic
938
+ && event.coveredEventIds.some((id) => activeIds.has(id))) {
939
+ await this.archiveCompactedEventsLocked(event.id);
940
+ }
941
+ }
942
+ }
892
943
  });
893
944
  }
894
945
 
@@ -1024,6 +1075,9 @@ export class AgentSessionEngine implements IAgentSession {
1024
1075
  ? this.transactionalStoreCapabilityError()
1025
1076
  : undefined;
1026
1077
  if (capabilityError) return Promise.reject(capabilityError);
1078
+ if (options.stop && !options.transaction) {
1079
+ return Promise.reject(new Error('A stop signal requires a transactional generation.'));
1080
+ }
1027
1081
  if (!options.key.trim()) return Promise.reject(new Error('Scheduled generation key must not be empty.'));
1028
1082
  const debounceMs = options.debounceMs ?? 50;
1029
1083
  if (!Number.isSafeInteger(debounceMs) || debounceMs < 0) {
@@ -1039,7 +1093,10 @@ export class AgentSessionEngine implements IAgentSession {
1039
1093
  || existing.cache !== options.cache
1040
1094
  || existing.maxSteps !== options.maxSteps
1041
1095
  || existing.abort !== options.abort
1096
+ || existing.stop !== options.stop
1042
1097
  || existing.prepare !== options.prepare
1098
+ || existing.pendingInput !== options.pendingInput
1099
+ || existing.onInputApplied !== options.onInputApplied
1043
1100
  || existing.transaction?.generationId !== options.transaction?.generationId
1044
1101
  || existing.transaction?.claimToken !== options.transaction?.claimToken
1045
1102
  ) {
@@ -1053,8 +1110,9 @@ export class AgentSessionEngine implements IAgentSession {
1053
1110
  return existing.promise;
1054
1111
  }
1055
1112
 
1056
- if (options.abort?.aborted) {
1057
- const abortReason = options.abort.reason ?? new DOMException('Aborted', 'AbortError');
1113
+ const endedBy = options.abort?.aborted ? options.abort : options.stop?.aborted ? options.stop : undefined;
1114
+ if (endedBy) {
1115
+ const abortReason = endedBy.reason ?? new DOMException('Aborted', 'AbortError');
1058
1116
  if (!options.transaction) return Promise.reject(abortReason);
1059
1117
  return this.queueExclusive(async () => {
1060
1118
  const transaction = this.verifyGenerationClaim(options.transaction!);
@@ -1082,9 +1140,11 @@ export class AgentSessionEngine implements IAgentSession {
1082
1140
  const controller = new AbortController();
1083
1141
  const onAbort = () => this.cancelScheduledGeneration(
1084
1142
  options.key,
1085
- options.abort?.reason ?? new DOMException('Aborted', 'AbortError'),
1143
+ (options.abort?.aborted ? options.abort.reason : options.stop?.reason)
1144
+ ?? new DOMException('Aborted', 'AbortError'),
1086
1145
  );
1087
1146
  options.abort?.addEventListener('abort', onAbort, { once: true });
1147
+ options.stop?.addEventListener('abort', onAbort, { once: true });
1088
1148
  const scheduled: IScheduledGeneration = {
1089
1149
  state: 'debouncing',
1090
1150
  debounceMs,
@@ -1095,11 +1155,17 @@ export class AgentSessionEngine implements IAgentSession {
1095
1155
  cache: options.cache,
1096
1156
  maxSteps: options.maxSteps,
1097
1157
  abort: options.abort,
1158
+ stop: options.stop,
1098
1159
  transaction: options.transaction,
1099
1160
  prepare: options.prepare,
1161
+ pendingInput: options.pendingInput,
1162
+ onInputApplied: options.onInputApplied,
1100
1163
  timer: setTimeout(() => this.startScheduledGeneration(options.key), debounceMs),
1101
1164
  controller,
1102
- removeAbortListener: () => options.abort?.removeEventListener('abort', onAbort),
1165
+ removeAbortListener: () => {
1166
+ options.abort?.removeEventListener('abort', onAbort);
1167
+ options.stop?.removeEventListener('abort', onAbort);
1168
+ },
1103
1169
  promise,
1104
1170
  resolve,
1105
1171
  reject,
@@ -1120,8 +1186,11 @@ export class AgentSessionEngine implements IAgentSession {
1120
1186
  cache: scheduled.cache,
1121
1187
  maxSteps: scheduled.maxSteps,
1122
1188
  abort: scheduled.controller.signal,
1189
+ stop: scheduled.stop,
1123
1190
  transaction: scheduled.transaction,
1124
1191
  prepare: scheduled.prepare,
1192
+ pendingInput: scheduled.pendingInput,
1193
+ onInputApplied: scheduled.onInputApplied,
1125
1194
  }).then(
1126
1195
  (result) => this.settleScheduledGeneration(key, scheduled, () => scheduled.resolve(result)),
1127
1196
  (error) => this.settleScheduledGeneration(key, scheduled, () => scheduled.reject(error)),
@@ -1149,6 +1218,7 @@ export class AgentSessionEngine implements IAgentSession {
1149
1218
  const cancellationReason = reason
1150
1219
  ?? new DOMException('Scheduled generation cancelled', 'AbortError');
1151
1220
  if (!scheduled.controller.signal.aborted) scheduled.controller.abort(cancellationReason);
1221
+ scheduled.pendingInput?.close();
1152
1222
  if (!scheduled.transaction) {
1153
1223
  scheduled.reject(cancellationReason);
1154
1224
  return true;
@@ -1180,7 +1250,7 @@ export class AgentSessionEngine implements IAgentSession {
1180
1250
  }
1181
1251
 
1182
1252
  private async performCompaction(
1183
- reason: 'context-overflow' | 'retention' | 'manual',
1253
+ reason: 'context-overflow' | 'context-budget' | 'retention' | 'manual',
1184
1254
  reportUsage: TAgentModelCallUsageReporter,
1185
1255
  abortSignal?: AbortSignal,
1186
1256
  ): Promise<IContextCompactionEvent | undefined> {
@@ -1189,7 +1259,7 @@ export class AgentSessionEngine implements IAgentSession {
1189
1259
  abortSignal?.throwIfAborted();
1190
1260
  const unresolvedIntents = getUncertainToolExecutionIntents(this.events);
1191
1261
  if (unresolvedIntents.length > 0) {
1192
- if (reason === 'context-overflow' || reason === 'retention') return undefined;
1262
+ if (reason === 'context-overflow' || reason === 'context-budget' || reason === 'retention') return undefined;
1193
1263
  throw new Error('Context compaction is blocked by unresolved tool execution intents.');
1194
1264
  }
1195
1265
  const openTransactions = getOpenAgentGenerationTransactions(this.events);
@@ -1199,7 +1269,7 @@ export class AgentSessionEngine implements IAgentSession {
1199
1269
  let coveredEvents: TAgentEvent[];
1200
1270
  if (openTransactions.length > 0) {
1201
1271
  if (reason === 'retention') return undefined;
1202
- if (reason !== 'context-overflow') {
1272
+ if (reason !== 'context-overflow' && reason !== 'context-budget') {
1203
1273
  throw new Error('Context compaction is blocked while a transactional generation is open.');
1204
1274
  }
1205
1275
  const openGenerationIds = new Set(
@@ -1257,6 +1327,7 @@ export class AgentSessionEngine implements IAgentSession {
1257
1327
  .slice(-(this.options.maxArchivedTransactionTombstones ?? 1000));
1258
1328
  const compaction = this.commitEvent(createAgentEvent<IContextCompactionEvent>({
1259
1329
  type: 'context-compaction',
1330
+ ...(reason === 'context-budget' ? { automatic: true as const } : {}),
1260
1331
  coveredEventIds: coveredEvents.map((event) => event.id),
1261
1332
  replacementEvents: modelMessagesToAgentEvents(replacementMessages),
1262
1333
  archivedTransactions: archivedTransactions.length > 0
@@ -1492,6 +1563,7 @@ export class AgentSessionEngine implements IAgentSession {
1492
1563
  preparationErrors: ReadonlyMap<string, unknown>,
1493
1564
  ledger: Map<string, IToolExecutionLedgerEntry>,
1494
1565
  getMessages: () => plugins.ModelMessage[],
1566
+ onPreview: (event: IAgentToolCallUpdateEvent) => void,
1495
1567
  ): plugins.ToolSet {
1496
1568
  const wrappedTools: plugins.ToolSet = {};
1497
1569
  for (const [toolName, toolDefinition] of Object.entries(tools)) {
@@ -1551,6 +1623,7 @@ export class AgentSessionEngine implements IAgentSession {
1551
1623
  result,
1552
1624
  resolveCompletion,
1553
1625
  rejectCompletion,
1626
+ (output) => onPreview({ toolCallId, toolName, input, output }),
1554
1627
  );
1555
1628
  }
1556
1629
  if (isPromiseLike(result)) {
@@ -1566,6 +1639,7 @@ export class AgentSessionEngine implements IAgentSession {
1566
1639
  deferredIterable,
1567
1640
  resolveCompletion,
1568
1641
  rejectCompletion,
1642
+ (output) => onPreview({ toolCallId, toolName, input, output }),
1569
1643
  );
1570
1644
  }
1571
1645
  resolveCompletion(result);
@@ -1590,6 +1664,7 @@ export class AgentSessionEngine implements IAgentSession {
1590
1664
  iterable: AsyncIterable<unknown>,
1591
1665
  resolveCompletion: (output: unknown) => void,
1592
1666
  rejectCompletion: (error: unknown) => void,
1667
+ onOutput: (output: unknown) => void,
1593
1668
  ): AsyncIterable<unknown> {
1594
1669
  const iterator = iterable[Symbol.asyncIterator]();
1595
1670
  let settled = false;
@@ -1621,7 +1696,11 @@ export class AgentSessionEngine implements IAgentSession {
1621
1696
  }
1622
1697
  return operationResult.then(
1623
1698
  (result) => {
1624
- if (!result.done && cacheOutput) lastOutput = result.value;
1699
+ if (!result.done && cacheOutput) {
1700
+ lastOutput = result.value;
1701
+ try { onOutput(result.value); }
1702
+ catch (error) { settleError(error); throw error; }
1703
+ }
1625
1704
  if (result.done) {
1626
1705
  if (rejectWhenDone) settleError(incompleteError());
1627
1706
  else settleSuccess();
@@ -1720,6 +1799,9 @@ export class AgentSessionEngine implements IAgentSession {
1720
1799
  ? this.transactionalStoreCapabilityError()
1721
1800
  : undefined;
1722
1801
  if (capabilityError) return Promise.reject(capabilityError);
1802
+ if (options.stop && !options.transaction) {
1803
+ return Promise.reject(new Error('A stop signal requires a transactional generation.'));
1804
+ }
1723
1805
  const run = this.generationQueue.then(async () => {
1724
1806
  if (!options.transaction) options.abort?.throwIfAborted();
1725
1807
  if (!options.transaction && this.sessionAbortController.signal.aborted) {
@@ -1730,6 +1812,9 @@ export class AgentSessionEngine implements IAgentSession {
1730
1812
  if (this.maintenanceError !== undefined) throw this.maintenanceError;
1731
1813
  await this.runEventRetentionLocked();
1732
1814
  return this.generateLocked(options);
1815
+ }).finally(() => {
1816
+ // The generation takes no further input; what it never took in stays with the queue.
1817
+ options.pendingInput?.close();
1733
1818
  });
1734
1819
  this.generationQueue = run.then(() => undefined, () => undefined);
1735
1820
  // A transactional generation is not raced against its abort signal: it settles when its
@@ -1801,6 +1886,7 @@ export class AgentSessionEngine implements IAgentSession {
1801
1886
  this.sessionAbortController.signal,
1802
1887
  generationAbortController.signal,
1803
1888
  options.abort,
1889
+ options.stop,
1804
1890
  ]);
1805
1891
 
1806
1892
  let lease: IAgentGenerationLease | undefined;
@@ -1842,6 +1928,7 @@ export class AgentSessionEngine implements IAgentSession {
1842
1928
  let attempt = 0;
1843
1929
  let retriedMs = 0;
1844
1930
  let contextOverflowRetries = 0;
1931
+ let automaticCompactionChecked = false;
1845
1932
  let totalInput = 0;
1846
1933
  let totalOutput = 0;
1847
1934
  let totalCacheRead = 0;
@@ -1862,6 +1949,8 @@ export class AgentSessionEngine implements IAgentSession {
1862
1949
  const reportCompactionUsage: TAgentModelCallUsageReporter = (call) =>
1863
1950
  recordModelCallUsage({ ...call, source: 'compaction', generationId });
1864
1951
  let currentInferenceId: string | undefined;
1952
+ /** Text the current inference streamed so far, kept if the inference is interrupted. */
1953
+ let inferenceText = '';
1865
1954
  let latestInferenceVisibleIds = new Set(this.events.map((event) => event.id));
1866
1955
  const completedInferenceIds = new Set<string>();
1867
1956
  const reasoningTextById = new Map<string, string>();
@@ -1873,6 +1962,7 @@ export class AgentSessionEngine implements IAgentSession {
1873
1962
  const ledger = new Map<string, IToolExecutionLedgerEntry>();
1874
1963
  const executionIntentIds = new Map<string, string>();
1875
1964
  const preliminaryToolOutputs = new Map<string, IAgentToolCallUpdateEvent>();
1965
+ const finishedToolCallIds = new Set<string>();
1876
1966
 
1877
1967
  const beginInference = async (): Promise<string> => {
1878
1968
  const marker = this.commitEvent(createAgentEvent<IRuntimeEvent>({
@@ -1889,10 +1979,90 @@ export class AgentSessionEngine implements IAgentSession {
1889
1979
  : undefined,
1890
1980
  }));
1891
1981
  currentInferenceId = marker.id;
1982
+ inferenceText = '';
1892
1983
  latestInferenceVisibleIds = new Set(this.events.map((event) => event.id));
1893
1984
  await this.flushPersistence();
1894
1985
  return marker.id;
1895
1986
  };
1987
+ const applyPendingInput = async (): Promise<void> => {
1988
+ const inputs = options.pendingInput?.drain() ?? [];
1989
+ if (inputs.length === 0) return;
1990
+ const applied: IAgentAppliedInput[] = [];
1991
+ try {
1992
+ for (const input of inputs) {
1993
+ const event = this.commitEvent(createAgentEvent<IUserMessageEvent>({
1994
+ type: 'user-message',
1995
+ content: input.content,
1996
+ inputId: input.inputId,
1997
+ generationId,
1998
+ parentIds: this.events.length > 0
1999
+ ? [this.events[this.events.length - 1].id]
2000
+ : undefined,
2001
+ }));
2002
+ applied.push({
2003
+ inputId: input.inputId,
2004
+ eventId: event.id,
2005
+ generationId,
2006
+ completedSteps: stepCount,
2007
+ });
2008
+ }
2009
+ await this.flushPersistence();
2010
+ } catch (error) {
2011
+ // Input that is not durable was not taken in: the queue returns it from close().
2012
+ options.pendingInput!.restore(inputs);
2013
+ throw error;
2014
+ }
2015
+ // Every durable input is reported, also when reporting an earlier one failed.
2016
+ const callbackErrors: unknown[] = [];
2017
+ for (const input of applied) {
2018
+ try {
2019
+ await this.invokeGenerationCallback(() => options.onInputApplied?.(input));
2020
+ } catch (error) {
2021
+ callbackErrors.push(error);
2022
+ }
2023
+ }
2024
+ if (callbackErrors.length === 1) throw callbackErrors[0];
2025
+ if (callbackErrors.length > 1) {
2026
+ throw new AggregateError(callbackErrors, 'Reporting applied pending input failed.');
2027
+ }
2028
+ };
2029
+ /**
2030
+ * Keeps what a stopped inference produced: the text it streamed and the tool calls it
2031
+ * issued, as one assistant message of the generation. Tool calls without a result are
2032
+ * closed when the stopped generation is rendered for the model.
2033
+ */
2034
+ const commitInterruptedInference = async (): Promise<void> => {
2035
+ const inferenceId = currentInferenceId;
2036
+ if (!options.stop?.aborted || !inferenceId || completedInferenceIds.has(inferenceId)) return;
2037
+ completedInferenceIds.add(inferenceId);
2038
+ if (this.events.some((event) =>
2039
+ event.type === 'assistant-message' && event.inferenceId === inferenceId)) return;
2040
+ const toolCallParts: TAgentToolCallPart[] = this.events
2041
+ .filter((event): event is IToolCallEvent =>
2042
+ event.type === 'tool-call'
2043
+ && event.generationId === generationId
2044
+ && event.inferenceId === inferenceId)
2045
+ .map((event) => ({
2046
+ type: 'tool-call',
2047
+ toolCallId: event.toolCallId,
2048
+ toolName: event.toolName,
2049
+ input: event.input,
2050
+ ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
2051
+ ...(event.providerOptions === undefined ? {} : { providerOptions: event.providerOptions }),
2052
+ }));
2053
+ if (!inferenceText && toolCallParts.length === 0) return;
2054
+ this.commitEvent(createAgentEvent<IAssistantMessageEvent>({
2055
+ type: 'assistant-message',
2056
+ content: [
2057
+ ...(inferenceText ? [{ type: 'text' as const, text: inferenceText }] : []),
2058
+ ...toolCallParts,
2059
+ ],
2060
+ generationId,
2061
+ inferenceId,
2062
+ causationId: inferenceId,
2063
+ }));
2064
+ await this.flushPersistence();
2065
+ };
1896
2066
  const allTools = this.wrapToolsForGeneration(
1897
2067
  generationId,
1898
2068
  runtime.tools,
@@ -1901,6 +2071,13 @@ export class AgentSessionEngine implements IAgentSession {
1901
2071
  preparationErrors,
1902
2072
  ledger,
1903
2073
  () => this.buildContext(this.eventsByIds(latestInferenceVisibleIds)),
2074
+ (event) => {
2075
+ const previous = preliminaryToolOutputs.get(event.toolCallId);
2076
+ if (!previous || !this.platform.isDeepEqual(previous.output, event.output)) {
2077
+ preliminaryToolOutputs.set(event.toolCallId, event);
2078
+ this.options.onToolCallUpdate?.(event);
2079
+ }
2080
+ },
1904
2081
  );
1905
2082
 
1906
2083
  const flushReasoning = (): void => {
@@ -1918,6 +2095,7 @@ export class AgentSessionEngine implements IAgentSession {
1918
2095
  let streamError: unknown;
1919
2096
  try {
1920
2097
  currentInferenceId = undefined;
2098
+ inferenceText = '';
1921
2099
  const result = plugins.streamText({
1922
2100
  model: runtime.model,
1923
2101
  instructions: runtime.system,
@@ -1929,6 +2107,15 @@ export class AgentSessionEngine implements IAgentSession {
1929
2107
  maxRetries: 0,
1930
2108
  abortSignal: combinedAbort.signal,
1931
2109
  prepareStep: async () => {
2110
+ await applyPendingInput();
2111
+ if (!automaticCompactionChecked && this.options.contextCompactor
2112
+ && this.options.contextCompactionBytes !== false) {
2113
+ automaticCompactionChecked = true;
2114
+ const bytes = new TextEncoder().encode(JSON.stringify(this.getModelMessages())).byteLength;
2115
+ if (bytes > (this.options.contextCompactionBytes ?? 256 * 1024)) {
2116
+ await this.performCompaction('context-budget', reportCompactionUsage, combinedAbort.signal);
2117
+ }
2118
+ }
1932
2119
  await beginInference();
1933
2120
  return { messages: this.buildContext(this.eventsByIds(latestInferenceVisibleIds)) };
1934
2121
  },
@@ -1959,7 +2146,9 @@ export class AgentSessionEngine implements IAgentSession {
1959
2146
  const chunkType = String((chunk as { type?: unknown }).type ?? '');
1960
2147
  if (chunkType === 'text-delta') {
1961
2148
  const textChunk = chunk as { delta?: string; textDelta?: string; text?: string };
1962
- this.options.onToken?.(textChunk.delta ?? textChunk.textDelta ?? textChunk.text ?? '');
2149
+ const delta = textChunk.delta ?? textChunk.textDelta ?? textChunk.text ?? '';
2150
+ inferenceText += delta;
2151
+ this.options.onToken?.(delta);
1963
2152
  return;
1964
2153
  }
1965
2154
  if (chunkType === 'tool-result') {
@@ -1971,6 +2160,10 @@ export class AgentSessionEngine implements IAgentSession {
1971
2160
  output: unknown;
1972
2161
  };
1973
2162
  if (toolResultChunk.preliminary === true) {
2163
+ // Locally executed previews are delivered at the iterator boundary, not from
2164
+ // this independently backpressured SDK stream, which may arrive after finish.
2165
+ if (ledger.has(toolResultChunk.toolCallId)) return;
2166
+ if (finishedToolCallIds.has(toolResultChunk.toolCallId)) return;
1974
2167
  const previous = preliminaryToolOutputs.get(toolResultChunk.toolCallId);
1975
2168
  if (!previous || !this.platform.isDeepEqual(previous.output, toolResultChunk.output)) {
1976
2169
  const event: IAgentToolCallUpdateEvent = {
@@ -2102,6 +2295,7 @@ export class AgentSessionEngine implements IAgentSession {
2102
2295
  return promise;
2103
2296
  },
2104
2297
  onToolExecutionEnd: async (finish) => {
2298
+ finishedToolCallIds.add(finish.toolCall.toolCallId);
2105
2299
  preliminaryToolOutputs.delete(finish.toolCall.toolCallId);
2106
2300
  const inferenceId = currentInferenceId ?? await beginInference();
2107
2301
  const previousResult = this.currentGenerationEvent(
@@ -2245,8 +2439,11 @@ export class AgentSessionEngine implements IAgentSession {
2245
2439
  flushReasoning();
2246
2440
  const effectiveError = streamError ?? error;
2247
2441
  if (combinedAbort.signal.aborted) {
2442
+ await commitInterruptedInference();
2248
2443
  throw combinedAbort.signal.reason ?? new DOMException('Aborted', 'AbortError');
2249
2444
  }
2445
+ // A failed attempt is retried or fails the generation; its partial text is not kept.
2446
+ inferenceText = '';
2250
2447
  if (effectiveError instanceof DOMException && effectiveError.name === 'AbortError') {
2251
2448
  throw effectiveError;
2252
2449
  }
@@ -2344,6 +2541,7 @@ export class AgentSessionEngine implements IAgentSession {
2344
2541
  generationId,
2345
2542
  'interrupted',
2346
2543
  this.transactionOutcomeErrorReason(effectiveError),
2544
+ options.stop?.aborted === true,
2347
2545
  );
2348
2546
  } else {
2349
2547
  const transaction = this.transaction(generationId);
@@ -2366,6 +2564,7 @@ export class AgentSessionEngine implements IAgentSession {
2366
2564
  generationId,
2367
2565
  'interrupted',
2368
2566
  this.transactionOutcomeErrorReason(abortError),
2567
+ options.stop?.aborted === true,
2369
2568
  );
2370
2569
  throw abortError;
2371
2570
  }
package/ts_agent/index.ts CHANGED
@@ -6,6 +6,7 @@ export {
6
6
  getAgentGenerationTransactions,
7
7
  getOpenAgentGenerationTransactions,
8
8
  getUncertainToolExecutionIntents,
9
+ isGenerationInModelContext,
9
10
  isTransactionalGeneration,
10
11
  } from './smartagent.transactions.js';
11
12
  export {
@@ -13,6 +14,15 @@ export {
13
14
  createAgentEventId,
14
15
  modelMessagesToAgentEvents,
15
16
  } from './smartagent.events.js';
17
+ export {
18
+ AgentPendingInputQueue,
19
+ AgentPendingInputRejectedError,
20
+ } from './smartagent.pendinginput.js';
21
+ export type {
22
+ IAgentAppliedInput,
23
+ IAgentPendingInput,
24
+ TAgentPendingInputRejection,
25
+ } from './smartagent.pendinginput.js';
16
26
  export { ToolRegistry } from './smartagent.classes.toolregistry.js';
17
27
  export { AgentModelCallUsageRecorder } from './smartagent.usage.js';
18
28
  export type { IAgentModelCallUsageRecorderModel } from './smartagent.usage.js';
@@ -35,6 +45,7 @@ export type {
35
45
  IAgentCompactOptions,
36
46
  IAgentEventRetentionOptions,
37
47
  IAgentBeginGenerationOptions,
48
+ IAgentFinalizeGenerationOptions,
38
49
  IAgentGenerateOptions,
39
50
  IAgentGenerateResult,
40
51
  IAgentGenerationHandle,
@@ -274,10 +274,34 @@ export async function createReviewedDraft(
274
274
 
275
275
  The `review` callback belongs to your application. While awaiting acceptance, `candidate.messages` includes the candidate, but `getModelMessages()` excludes it from ordinary future context. Acceptance includes the generation; rejection keeps it in the canonical event history while excluding it from future context. Generation IDs identify transactions within a session; use a distinct ID for each new transaction.
276
276
 
277
+ `generate({ transaction, stop })` takes a second signal for the owner's stop, the way a user stops a turn. It ends the generation like `abort`, but the interrupted outcome records the stop (`stopped: true`, also accepted by `finalizeGeneration(transaction, 'interrupted', { stopped: true })` for a generation stopped after its execution completed). A stopped generation stays in future context once its model produced output: its prompt, the input it took in, the text its running model step streamed and the tool calls it issued. Each tool call without a recorded result is closed with an error result saying its effect is unknown, so the next request is well-formed for every provider. A generation stopped before any model output leaves no trace, so its prompt can be sent again without appearing twice. `isGenerationInModelContext(events, generationId)` applies the same rule to a list of events, for a caller that needs to know whether a finished generation stayed in context. Aborted and failed generations, and generations a restart interrupted, stay out of future context as before. `stop` requires a transactional generation.
278
+
277
279
  Closing a session or restoring a stored session interrupts unfinished transactions. Restoration does not automatically rerun them. Persisted transactions require an `IAgentEventStoreV2` store with `eventSchemaVersion: 2`; without a store, transactions are process-local.
278
280
 
279
281
  A rejected generation does not roll back external tool effects. The runtime records transactional tool-execution intents and results. If a crash leaves an intent without a known result, inspect `listUncertainToolExecutions()` and reconcile the external operation through `reconcileToolExecution()` before treating its outcome as known. This is not a general exactly-once guarantee for external services.
280
282
 
283
+ ## Take input while a generation runs
284
+
285
+ Pass an `AgentPendingInputQueue` to `generate({ pendingInput })` to let the user steer a running generation. The generation takes waiting input in at every step boundary, after the tool results of the previous model step are recorded and before the next model call, and records each input as a user message of the generation (`inputId` set). A running tool call is never interrupted. `onInputApplied` is called for each input once it is durable, before the next model call; every durable input is reported, also when reporting an earlier one throws, and the generation then fails with that error. Input the generation could not record durably counts as never taken in: the generation gives it back to the queue with `restore()`.
286
+
287
+ ```typescript
288
+ import { AgentPendingInputQueue } from '@modelprofile.com/flexharness-agent';
289
+
290
+ const pendingInput = new AgentPendingInputQueue();
291
+ const transaction = await session.beginGeneration('Plan the release.');
292
+ const generation = session.generate({
293
+ transaction,
294
+ pendingInput,
295
+ onInputApplied: ({ inputId, eventId }) => markApplied(inputId, eventId),
296
+ });
297
+ // While the generation runs:
298
+ pendingInput.add({ inputId: 'steer-1', content: 'Keep the migration out of it.' });
299
+ await generation;
300
+ const unapplied = pendingInput.close(); // input the generation never took in
301
+ ```
302
+
303
+ The generation closes the queue when it ends, whether it completed, failed or was aborted. `close()` returns the inputs it never took in, the same list on every call once the generation has ended; input that arrives during the last model step is among them, and so is input the generation drained but could not record durably. After closing, `add()` throws `AgentPendingInputRejectedError` with reason `closed`, and a reused `inputId` is refused with reason `duplicate`. The caller decides what becomes of unapplied input, for example sending it as the next turn.
304
+
281
305
  ## Events, storage and context
282
306
 
283
307
  Canonical events retain user and assistant messages, tool calls and results, runtime events, compaction records and transaction outcomes. `getEvents()` returns this history; `getModelMessages()` projects the model-visible conversation through the configured `contextBuilder`, which defaults to `buildModelMessages`.
@@ -297,12 +321,15 @@ export function openStoredSession(eventStore: IAgentEventStoreV2, sessionId: str
297
321
 
298
322
  Use an application-owned durable adapter for persistent sessions. Its `save(sessionId, events, expectedRevision)` must atomically compare the revision and return the new revision, or reject a conflict. Preserve the schema and supported values in canonical events; the package exports schema validators and `AgentEventStoreConflictError` for adapter implementations. Keep one active owner per session; compare-and-swap detects conflicting writes rather than merging competing sessions.
299
323
 
324
+ Commits queued before a save starts share one durable snapshot containing them all. An in-flight snapshot is immutable, and every caller and change notification still waits for the save containing its event. The adapter's revision advances per durable snapshot, not necessarily per event.
325
+
300
326
  `InMemoryAgentEventStore` is useful for tests and sessions that only need process-local storage. The Node root also exports the explicit `FileAgentEventStore` adapter. Neither storage selection nor a durable backend is implicit.
301
327
 
302
328
  ### Bound model context and active events
303
329
 
304
330
  - `contextBuilder({ events })` controls the model-message projection.
305
331
  - `contextCompactor(messages, events, { reason, abortSignal, reportUsage })` returns replacement model messages. Provide it to use `session.compact()` or automatic event retention. Report the usage of its model calls through `reportUsage`; see [What is counted](#what-is-counted).
332
+ - A compactor also enables proactive compaction with reason `context-budget` before inference. `contextCompactionBytes` defaults to 256 KiB of UTF-8 JSON model messages, accepts 1 through 32 MiB, and may be `false` to disable it. Only settled history is covered, never the current unsettled generation. Budget compactions carry `automatic: true`; after transactional finalization their covered events are archived when the store supports it and no open transaction or uncertain tool intent remains. Manual compaction is not automatically archived.
306
333
  - `eventRetention: { maxEvents }` triggers compaction and archival when the active event count exceeds the threshold. It also requires an event store with `archive()` support.
307
334
  - Context overflow invokes the configured compactor, or the `onContextOverflow(messages, { abortSignal, reportUsage })` handler. Without either, generation throws `ContextOverflowError`. `maxContextOverflowRetries` defaults to `3`.
308
335
  - Open transactions and uncertain tool intents constrain when compaction and archival can proceed. Resolve them before manual compaction.
@@ -35,6 +35,8 @@ export interface IUserMessageEvent extends IAgentEventBase {
35
35
  type: 'user-message';
36
36
  content: TAgentUserMessage['content'];
37
37
  providerOptions?: TAgentUserMessage['providerOptions'];
38
+ /** Set when a running generation took the message in from its pending input. */
39
+ inputId?: string;
38
40
  }
39
41
 
40
42
  export interface IAssistantMessageEvent extends IAgentEventBase {
@@ -106,6 +108,11 @@ export interface IGenerationOutcomeEvent extends IAgentEventBase {
106
108
  generationId: string;
107
109
  outcome: TAgentGenerationOutcome;
108
110
  reason?: string;
111
+ /**
112
+ * Set on an `interrupted` outcome whose owner stopped the generation: what its model produced
113
+ * stays in the model context.
114
+ */
115
+ stopped?: boolean;
109
116
  }
110
117
 
111
118
  export interface IToolExecutionIntentEvent extends IAgentEventBase {
@@ -143,6 +150,8 @@ export type TAgentConversationEvent =
143
150
 
144
151
  export interface IContextCompactionEvent extends IAgentEventBase {
145
152
  type: 'context-compaction';
153
+ /** A proactive budget compaction whose settled source events may be archived automatically. */
154
+ automatic?: true;
146
155
  coveredEventIds: string[];
147
156
  replacementEvents: TAgentConversationEvent[];
148
157
  archivedTransactions?: IArchivedAgentGenerationTransaction[];