@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.
@@ -17,6 +17,7 @@ import type {
17
17
  TAgentToolResultOutput,
18
18
  } from './smartagent.events.js';
19
19
  import type { TAgentEventArchive, TAgentEventStore } from './smartagent.persistence.js';
20
+ import type { AgentPendingInputQueue, IAgentAppliedInput } from './smartagent.pendinginput.js';
20
21
  import type { IAgentRetryEvent } from './smartagent.retry.js';
21
22
 
22
23
  export type { ProviderOptions };
@@ -129,7 +130,7 @@ export type TAgentContextBuilder = (
129
130
 
130
131
  export interface IAgentContextCompactionOptions {
131
132
  abortSignal?: AbortSignal;
132
- reason: 'context-overflow' | 'retention' | 'manual';
133
+ reason: 'context-overflow' | 'context-budget' | 'retention' | 'manual';
133
134
  /**
134
135
  * Reports the usage of each model call the compactor makes, into the session's `onUsage` as
135
136
  * `source: 'compaction'`. The session always supplies it. Report every call before the returned
@@ -189,6 +190,11 @@ export interface IAgentSessionOptions {
189
190
  contextBuilder?: TAgentContextBuilder;
190
191
  /** Explicit context compactor used by manual and event-retention compaction. */
191
192
  contextCompactor?: TAgentContextCompactor;
193
+ /** Compact settled history before inference when model messages exceed this UTF-8 JSON byte budget.
194
+ * Defaults to 256 KiB when a compactor is supplied; false disables proactive compaction.
195
+ * The current unsettled generation is never summarized or archived.
196
+ */
197
+ contextCompactionBytes?: number | false;
192
198
  /** Optional bounded active-event policy. Requires contextCompactor and eventStore.archive. */
193
199
  eventRetention?: IAgentEventRetentionOptions;
194
200
  /** Maximum wait for each session change listener. Default: 30 seconds. */
@@ -273,6 +279,14 @@ export interface IAgentBeginGenerationOptions {
273
279
  generationId?: string;
274
280
  }
275
281
 
282
+ export interface IAgentFinalizeGenerationOptions {
283
+ /**
284
+ * With outcome `interrupted`: the owner stopped the generation, so what its model produced
285
+ * stays in the model context.
286
+ */
287
+ stopped?: boolean;
288
+ }
289
+
276
290
  export interface IAgentGenerationPreparationContext {
277
291
  generationId: string;
278
292
  abortSignal: AbortSignal;
@@ -337,6 +351,13 @@ export interface IAgentGenerateOptions {
337
351
  maxSteps?: number;
338
352
  /** Cancels only this generation and its synchronous tool executions. */
339
353
  abort?: AbortSignal;
354
+ /**
355
+ * Stops a transactional generation the way a user stops a turn. It ends the generation like
356
+ * `abort`, but the interrupted outcome records the stop: the text the running model step
357
+ * streamed and the tool calls it issued are kept, and the generation stays in the model
358
+ * context once its model produced output. An aborted or failed generation is not kept.
359
+ */
360
+ stop?: AbortSignal;
340
361
  /** Opt-in transactional claim returned by beginGeneration(). */
341
362
  transaction?: IAgentGenerationHandle;
342
363
  /**
@@ -346,6 +367,14 @@ export interface IAgentGenerateOptions {
346
367
  * The callback owns cleanup of partial acquisition if it throws before returning a lease.
347
368
  */
348
369
  prepare?: TAgentGenerationPrepare;
370
+ /**
371
+ * User input the generation takes in at its step boundaries, recorded as user messages of
372
+ * the generation. The generation closes the queue when it ends; `close()` then returns the
373
+ * inputs it never took in.
374
+ */
375
+ pendingInput?: AgentPendingInputQueue;
376
+ /** Called once per input taken from `pendingInput`, after it is durable and before the next model call. */
377
+ onInputApplied?: (input: IAgentAppliedInput) => void | Promise<void>;
349
378
  }
350
379
 
351
380
  export interface IAgentScheduleGenerateOptions extends IAgentGenerateOptions {
@@ -413,6 +442,7 @@ export interface IAgentSession {
413
442
  finalizeGeneration(
414
443
  transaction: IAgentGenerationHandle,
415
444
  outcome: TAgentGenerationOutcome,
445
+ options?: IAgentFinalizeGenerationOptions,
416
446
  ): Promise<void>;
417
447
  listUncertainToolExecutions(): IToolExecutionIntentEvent[];
418
448
  reconcileToolExecution(
@@ -0,0 +1,116 @@
1
+ import type { TAgentPrompt } from './smartagent.interfaces.js';
2
+
3
+ /** One user input that a running generation takes in at its next step boundary. */
4
+ export interface IAgentPendingInput {
5
+ /** Caller-chosen identity, unique within its queue. */
6
+ inputId: string;
7
+ content: TAgentPrompt;
8
+ }
9
+
10
+ /** A pending input the generation recorded as a user message of its own. */
11
+ export interface IAgentAppliedInput {
12
+ inputId: string;
13
+ /** The `user-message` event that carries the input. */
14
+ eventId: string;
15
+ generationId: string;
16
+ /** Model steps the generation had completed when it took the input in. */
17
+ completedSteps: number;
18
+ }
19
+
20
+ export type TAgentPendingInputRejection = 'duplicate' | 'closed';
21
+
22
+ /** The queue refused an input: its id was used before, or its generation has ended. */
23
+ export class AgentPendingInputRejectedError extends Error {
24
+ constructor(
25
+ public readonly reason: TAgentPendingInputRejection,
26
+ public readonly inputId: string,
27
+ ) {
28
+ super(reason === 'duplicate'
29
+ ? `Pending input "${inputId}" was already added.`
30
+ : `Pending input "${inputId}" arrived after its generation stopped taking input.`);
31
+ this.name = 'AgentPendingInputRejectedError';
32
+ }
33
+ }
34
+
35
+ const assertPrompt = (content: TAgentPrompt): void => {
36
+ if (typeof content === 'string') {
37
+ if (!content.trim()) throw new Error('Pending input content must not be empty.');
38
+ return;
39
+ }
40
+ if (!Array.isArray(content) || content.length === 0) {
41
+ throw new Error('Pending input content must be a non-empty string or part list.');
42
+ }
43
+ };
44
+
45
+ /**
46
+ * The user input a generation takes in while it runs. A generation that receives the queue
47
+ * through `generate({ pendingInput })` drains it at every step boundary, after the tool results
48
+ * of its previous model step are recorded and before its next model call, and records each
49
+ * input as a user message of its own. A running tool call or permission wait is never
50
+ * interrupted: its input waits for the boundary that follows it.
51
+ *
52
+ * `close()` ends intake and returns the inputs the generation never took in: the ones still
53
+ * waiting, and the ones it drained but could not record durably, which it `restore()`s. An input
54
+ * added after it is refused with reason `closed`. The generation closes the queue when it ends,
55
+ * so every input is either applied or returned by `close()`. Once the generation has settled,
56
+ * `close()` returns the same inputs on every call; before that, a batch the generation `restore()`s
57
+ * after a failed save is added to what it returns.
58
+ */
59
+ export class AgentPendingInputQueue {
60
+ /** Inputs not taken in, in arrival order. */
61
+ private readonly waiting: IAgentPendingInput[] = [];
62
+ private readonly inputIds = new Set<string>();
63
+ private isClosed = false;
64
+
65
+ /** True once `close()` ended intake. */
66
+ public get closed(): boolean {
67
+ return this.isClosed;
68
+ }
69
+
70
+ /** Inputs waiting for the next step boundary. */
71
+ public get size(): number {
72
+ return this.isClosed ? 0 : this.waiting.length;
73
+ }
74
+
75
+ public add(input: IAgentPendingInput): void {
76
+ if (typeof input.inputId !== 'string' || !input.inputId.trim()) {
77
+ throw new Error('Pending input id must be a non-empty string.');
78
+ }
79
+ assertPrompt(input.content);
80
+ if (this.inputIds.has(input.inputId)) {
81
+ throw new AgentPendingInputRejectedError('duplicate', input.inputId);
82
+ }
83
+ if (this.isClosed) throw new AgentPendingInputRejectedError('closed', input.inputId);
84
+ this.inputIds.add(input.inputId);
85
+ this.waiting.push({ inputId: input.inputId, content: structuredClone(input.content) });
86
+ }
87
+
88
+ /** Takes every waiting input in arrival order; a closed queue has none left to take. */
89
+ public drain(): IAgentPendingInput[] {
90
+ if (this.isClosed) return [];
91
+ return this.waiting.splice(0, this.waiting.length);
92
+ }
93
+
94
+ /**
95
+ * Gives back inputs a generation drained but could not record durably. They count as never
96
+ * taken in and go before the inputs that arrived after them; a closed queue returns them from
97
+ * `close()`, an open one hands them out again at the next `drain()`.
98
+ */
99
+ public restore(inputs: readonly IAgentPendingInput[]): void {
100
+ for (const input of inputs) {
101
+ if (!this.inputIds.has(input.inputId)) {
102
+ throw new Error(`Pending input "${input.inputId}" was never added to this queue.`);
103
+ }
104
+ if (this.waiting.some((waiting) => waiting.inputId === input.inputId)) {
105
+ throw new Error(`Pending input "${input.inputId}" is still waiting.`);
106
+ }
107
+ }
108
+ this.waiting.unshift(...inputs);
109
+ }
110
+
111
+ /** Ends intake and returns the inputs that were never taken in, in arrival order. */
112
+ public close(): readonly IAgentPendingInput[] {
113
+ this.isClosed = true;
114
+ return Object.freeze([...this.waiting]);
115
+ }
116
+ }
@@ -392,6 +392,9 @@ const validateAgentEvents = (
392
392
  ? 'user'
393
393
  : 'assistant');
394
394
  assertProviderOptions(eventValue.providerOptions, `${eventPath}.providerOptions`);
395
+ if (eventValue.type === 'user-message' && eventValue.inputId !== undefined) {
396
+ assertNonEmptyString(eventValue.inputId, `${eventPath}.inputId`);
397
+ }
395
398
  break;
396
399
  case 'tool-call':
397
400
  assertNonEmptyString(eventValue.toolCallId, `${eventPath}.toolCallId`);
@@ -447,6 +450,9 @@ const validateAgentEvents = (
447
450
  && eventValue.outcome !== 'interrupted'
448
451
  ) validationError(`${eventPath}.outcome`, 'is invalid.');
449
452
  assertOptionalString(eventValue.reason, `${eventPath}.reason`);
453
+ if (eventValue.stopped !== undefined && (
454
+ eventValue.stopped !== true || eventValue.outcome !== 'interrupted'
455
+ )) validationError(`${eventPath}.stopped`, 'is only valid as true on an interrupted outcome.');
450
456
  break;
451
457
  case 'tool-execution-intent':
452
458
  assertRequiredGenerationId(eventValue, eventPath);
@@ -470,6 +476,9 @@ const validateAgentEvents = (
470
476
  }
471
477
  break;
472
478
  case 'context-compaction':
479
+ if (eventValue.automatic !== undefined && eventValue.automatic !== true) {
480
+ validationError(`${eventPath}.automatic`, 'must be true when present.');
481
+ }
473
482
  assertStringArray(eventValue.coveredEventIds, `${eventPath}.coveredEventIds`);
474
483
  validateAgentEvents(eventValue.replacementEvents, `${eventPath}.replacementEvents`, true);
475
484
  if (eventValue.archivedTransactions !== undefined) {
@@ -4,6 +4,7 @@ import type {
4
4
  IGenerationExecutionStartedEvent,
5
5
  IGenerationOutcomeEvent,
6
6
  IToolExecutionIntentEvent,
7
+ IToolResultEvent,
7
8
  TAgentControlEvent,
8
9
  TAgentEvent,
9
10
  TAgentGenerationOutcome,
@@ -138,11 +139,104 @@ export const isTransactionalGeneration = (
138
139
  generationId: string,
139
140
  ): boolean => getAgentGenerationTransactions(events).has(generationId);
140
141
 
142
+ /** The model output that closes a tool call its stopped generation left without a result. */
143
+ export const INTERRUPTED_TOOL_CALL_RESULT =
144
+ 'The tool call was interrupted before its result was recorded; its effect is unknown.';
145
+
146
+ const isModelOutputEvent = (event: TAgentEvent): boolean =>
147
+ event.type === 'assistant-message'
148
+ || event.type === 'tool-call'
149
+ || event.type === 'tool-result'
150
+ || event.type === 'model-message';
151
+
152
+ /**
153
+ * Whether the events of a transactional generation are part of the model context. An accepted
154
+ * generation is, and so is the generation that is executing. A generation its owner stopped
155
+ * stays in the context once the model produced output in it: its prompt, the input it took in
156
+ * while it ran and what the model produced before the stop. One stopped before any model output
157
+ * left no trace in the conversation and is not shown, so its prompt can be sent again. Rejected
158
+ * generations, and generations interrupted by an abort, a failure or a restart, are not shown.
159
+ */
160
+ const isModelVisibleTransaction = (
161
+ transaction: IAgentGenerationTransaction,
162
+ producedOutput: ReadonlySet<string>,
163
+ ): boolean =>
164
+ transaction.state === 'accepted'
165
+ || transaction.state === 'execution-started'
166
+ || (
167
+ transaction.state === 'interrupted'
168
+ && transaction.outcome?.stopped === true
169
+ && transaction.executionStarted !== undefined
170
+ && producedOutput.has(transaction.generationId)
171
+ );
172
+
173
+ /** The generations whose model produced output in `events`. */
174
+ const generationsWithModelOutput = (events: readonly TAgentEvent[]): Set<string> => {
175
+ const producedOutput = new Set<string>();
176
+ for (const event of events) {
177
+ if (event.generationId && isModelOutputEvent(event)) producedOutput.add(event.generationId);
178
+ }
179
+ return producedOutput;
180
+ };
181
+
182
+ /**
183
+ * Whether the transactional generation `generationId` is part of the model context of later
184
+ * generations, by the rule `filterModelVisibleAgentEvents` applies: it was accepted, is
185
+ * executing, or was stopped by its owner after its model produced output. False for a
186
+ * generation `events` holds no transaction of.
187
+ */
188
+ export const isGenerationInModelContext = (
189
+ events: readonly TAgentEvent[],
190
+ generationId: string,
191
+ ): boolean => {
192
+ const transaction = getAgentGenerationTransactions(events).get(generationId);
193
+ return transaction !== undefined
194
+ && isModelVisibleTransaction(transaction, generationsWithModelOutput(events));
195
+ };
196
+
197
+ const interruptedToolResult = (
198
+ toolCallId: string,
199
+ toolName: string,
200
+ source: TAgentEvent,
201
+ ): IToolResultEvent => ({
202
+ id: `${source.id}:interrupted:${toolCallId}`,
203
+ timestamp: source.timestamp,
204
+ type: 'tool-result',
205
+ toolCallId,
206
+ toolName,
207
+ error: INTERRUPTED_TOOL_CALL_RESULT,
208
+ modelOutput: { type: 'error-text', value: INTERRUPTED_TOOL_CALL_RESULT },
209
+ generationId: source.generationId,
210
+ inferenceId: source.inferenceId,
211
+ causationId: source.causationId,
212
+ parentIds: [source.id],
213
+ });
214
+
141
215
  export const filterModelVisibleAgentEvents = (
142
216
  events: readonly TAgentEvent[],
143
217
  ): TAgentEvent[] => {
144
218
  const transactions = getAgentGenerationTransactions(events);
219
+ const producedOutput = generationsWithModelOutput(events);
220
+ const resultedToolCalls = new Set<string>();
221
+ for (const event of events) {
222
+ if (!event.generationId) continue;
223
+ if (event.type === 'tool-result') resultedToolCalls.add(`${event.generationId}\0${event.toolCallId}`);
224
+ if (event.type === 'assistant-message' && Array.isArray(event.content)) {
225
+ for (const part of event.content) {
226
+ if (part.type === 'tool-result') {
227
+ resultedToolCalls.add(`${event.generationId}\0${part.toolCallId}`);
228
+ }
229
+ }
230
+ }
231
+ }
145
232
  const visibleEvents: TAgentEvent[] = [];
233
+ /** Closes each tool call a stopped generation left open, right after the call. */
234
+ const closeOpenToolCall = (toolCallId: string, toolName: string, source: TAgentEvent): void => {
235
+ const key = `${source.generationId}\0${toolCallId}`;
236
+ if (resultedToolCalls.has(key)) return;
237
+ resultedToolCalls.add(key);
238
+ visibleEvents.push(interruptedToolResult(toolCallId, toolName, source));
239
+ };
146
240
  for (const event of events) {
147
241
  if (isControlEvent(event)) continue;
148
242
  if (event.type === 'context-compaction' && event.archivedTransactions) {
@@ -154,11 +248,18 @@ export const filterModelVisibleAgentEvents = (
154
248
  continue;
155
249
  }
156
250
  const transaction = transactions.get(event.generationId);
157
- if (
158
- !transaction
159
- || transaction.state === 'accepted'
160
- || transaction.state === 'execution-started'
161
- ) visibleEvents.push(event);
251
+ if (transaction && !isModelVisibleTransaction(transaction, producedOutput)) continue;
252
+ visibleEvents.push(event);
253
+ if (transaction?.state !== 'interrupted') continue;
254
+ if (event.type === 'tool-call' && event.providerExecuted !== true) {
255
+ closeOpenToolCall(event.toolCallId, event.toolName, event);
256
+ } else if (event.type === 'assistant-message' && Array.isArray(event.content)) {
257
+ for (const part of event.content) {
258
+ if (part.type === 'tool-call' && part.providerExecuted !== true) {
259
+ closeOpenToolCall(part.toolCallId, part.toolName, event);
260
+ }
261
+ }
262
+ }
162
263
  }
163
264
  return visibleEvents;
164
265
  };