@modelprofile.com/flexharness-agent 9.9.1 → 9.10.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 };
@@ -273,6 +274,14 @@ export interface IAgentBeginGenerationOptions {
273
274
  generationId?: string;
274
275
  }
275
276
 
277
+ export interface IAgentFinalizeGenerationOptions {
278
+ /**
279
+ * With outcome `interrupted`: the owner stopped the generation, so what its model produced
280
+ * stays in the model context.
281
+ */
282
+ stopped?: boolean;
283
+ }
284
+
276
285
  export interface IAgentGenerationPreparationContext {
277
286
  generationId: string;
278
287
  abortSignal: AbortSignal;
@@ -337,6 +346,13 @@ export interface IAgentGenerateOptions {
337
346
  maxSteps?: number;
338
347
  /** Cancels only this generation and its synchronous tool executions. */
339
348
  abort?: AbortSignal;
349
+ /**
350
+ * Stops a transactional generation the way a user stops a turn. It ends the generation like
351
+ * `abort`, but the interrupted outcome records the stop: the text the running model step
352
+ * streamed and the tool calls it issued are kept, and the generation stays in the model
353
+ * context once its model produced output. An aborted or failed generation is not kept.
354
+ */
355
+ stop?: AbortSignal;
340
356
  /** Opt-in transactional claim returned by beginGeneration(). */
341
357
  transaction?: IAgentGenerationHandle;
342
358
  /**
@@ -346,6 +362,14 @@ export interface IAgentGenerateOptions {
346
362
  * The callback owns cleanup of partial acquisition if it throws before returning a lease.
347
363
  */
348
364
  prepare?: TAgentGenerationPrepare;
365
+ /**
366
+ * User input the generation takes in at its step boundaries, recorded as user messages of
367
+ * the generation. The generation closes the queue when it ends; `close()` then returns the
368
+ * inputs it never took in.
369
+ */
370
+ pendingInput?: AgentPendingInputQueue;
371
+ /** Called once per input taken from `pendingInput`, after it is durable and before the next model call. */
372
+ onInputApplied?: (input: IAgentAppliedInput) => void | Promise<void>;
349
373
  }
350
374
 
351
375
  export interface IAgentScheduleGenerateOptions extends IAgentGenerateOptions {
@@ -413,6 +437,7 @@ export interface IAgentSession {
413
437
  finalizeGeneration(
414
438
  transaction: IAgentGenerationHandle,
415
439
  outcome: TAgentGenerationOutcome,
440
+ options?: IAgentFinalizeGenerationOptions,
416
441
  ): Promise<void>;
417
442
  listUncertainToolExecutions(): IToolExecutionIntentEvent[];
418
443
  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);
@@ -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
  };