@modelprofile.com/flexharness 8.2.0 → 8.4.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.
@@ -26,24 +26,66 @@ When constructing the summary, try to stick to this template:
26
26
  *
27
27
  * @param model The same model used by runAgent, or a cheaper small model
28
28
  * @param messages The full message history that overflowed
29
- * @param options Invocation options, including cancellation
29
+ * @param options Invocation options, including cancellation and usage reporting
30
30
  * @returns A minimal ModelMessage[] containing the summary as context
31
31
  */
32
32
  export async function compactMessages(model, messages, options = {}) {
33
- const result = await plugins.generateText({
33
+ const prompt = [
34
+ ...messages,
35
+ { role: 'user', content: COMPACTION_PROMPT },
36
+ ];
37
+ const reportUsage = options.reportUsage;
38
+ if (!reportUsage) {
39
+ const result = await plugins.generateText({
40
+ model,
41
+ abortSignal: options.abortSignal,
42
+ allowSystemInMessages: true,
43
+ messages: prompt,
44
+ });
45
+ return summaryMessages(result.text);
46
+ }
47
+ const modelCalls = new plugins.AgentModelCallUsageRecorder(model, reportUsage);
48
+ // Each attempt, retries included, is one model call: it starts in the middleware, which also
49
+ // settles an attempt that throws; a call whose response arrives ends in onLanguageModelCallEnd.
50
+ const observedModel = plugins.wrapLanguageModel({
34
51
  model,
35
- abortSignal: options.abortSignal,
36
- allowSystemInMessages: true,
37
- messages: [
38
- ...messages,
39
- { role: 'user', content: COMPACTION_PROMPT },
40
- ],
52
+ middleware: {
53
+ wrapGenerate: async ({ doGenerate, params }) => {
54
+ modelCalls.start();
55
+ try {
56
+ return await doGenerate();
57
+ }
58
+ catch (error) {
59
+ modelCalls.settle(params.abortSignal?.aborted ? 'aborted' : 'failed');
60
+ throw error;
61
+ }
62
+ },
63
+ },
41
64
  });
65
+ try {
66
+ const result = await plugins.generateText({
67
+ model: observedModel,
68
+ abortSignal: options.abortSignal,
69
+ allowSystemInMessages: true,
70
+ messages: prompt,
71
+ onLanguageModelCallEnd: ({ modelId, usage }) => {
72
+ modelCalls.end(modelId, usage);
73
+ },
74
+ });
75
+ modelCalls.settle('missing');
76
+ return summaryMessages(result.text);
77
+ }
78
+ catch (error) {
79
+ modelCalls.settle(options.abortSignal?.aborted ? 'aborted' : 'failed');
80
+ throw error;
81
+ }
82
+ }
83
+ function summaryMessages(summary) {
42
84
  return [
43
85
  {
44
86
  role: 'user',
45
- content: `[Previous conversation summary]\n\n${result.text}\n\n[End of summary. Continue from here.]`,
87
+ content: `[Previous conversation summary]\n\n${summary}\n\n[End of summary. Continue from here.]`,
46
88
  },
47
89
  ];
48
90
  }
49
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnRhZ2VudC5jb21wYWN0aW9uLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHNfY29tcGFjdGlvbi9zbWFydGFnZW50LmNvbXBhY3Rpb24udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLE9BQU8sTUFBTSxjQUFjLENBQUM7QUFFeEMsTUFBTSxpQkFBaUIsR0FBRzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7SUFvQnRCLENBQUM7QUFNTDs7Ozs7Ozs7R0FRRztBQUNILE1BQU0sQ0FBQyxLQUFLLFVBQVUsZUFBZSxDQUNuQyxLQUFvQyxFQUNwQyxRQUFnQyxFQUNoQyxVQUFtQyxFQUFFO0lBRXJDLE1BQU0sTUFBTSxHQUFHLE1BQU0sT0FBTyxDQUFDLFlBQVksQ0FBQztRQUN4QyxLQUFLO1FBQ0wsV0FBVyxFQUFFLE9BQU8sQ0FBQyxXQUFXO1FBQ2hDLHFCQUFxQixFQUFFLElBQUk7UUFDM0IsUUFBUSxFQUFFO1lBQ1IsR0FBRyxRQUFRO1lBQ1gsRUFBRSxJQUFJLEVBQUUsTUFBTSxFQUFFLE9BQU8sRUFBRSxpQkFBaUIsRUFBRTtTQUM3QztLQUNGLENBQUMsQ0FBQztJQUVILE9BQU87UUFDTDtZQUNFLElBQUksRUFBRSxNQUFNO1lBQ1osT0FBTyxFQUFFLHNDQUFzQyxNQUFNLENBQUMsSUFBSSwyQ0FBMkM7U0FDdEc7S0FDRixDQUFDO0FBQ0osQ0FBQyJ9
91
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnRhZ2VudC5jb21wYWN0aW9uLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHNfY29tcGFjdGlvbi9zbWFydGFnZW50LmNvbXBhY3Rpb24udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLE9BQU8sTUFBTSxjQUFjLENBQUM7QUFFeEMsTUFBTSxpQkFBaUIsR0FBRzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7SUFvQnRCLENBQUM7QUFhTDs7Ozs7Ozs7R0FRRztBQUNILE1BQU0sQ0FBQyxLQUFLLFVBQVUsZUFBZSxDQUNuQyxLQUFvQyxFQUNwQyxRQUFnQyxFQUNoQyxVQUFtQyxFQUFFO0lBRXJDLE1BQU0sTUFBTSxHQUEyQjtRQUNyQyxHQUFHLFFBQVE7UUFDWCxFQUFFLElBQUksRUFBRSxNQUFNLEVBQUUsT0FBTyxFQUFFLGlCQUFpQixFQUFFO0tBQzdDLENBQUM7SUFDRixNQUFNLFdBQVcsR0FBRyxPQUFPLENBQUMsV0FBVyxDQUFDO0lBQ3hDLElBQUksQ0FBQyxXQUFXLEVBQUUsQ0FBQztRQUNqQixNQUFNLE1BQU0sR0FBRyxNQUFNLE9BQU8sQ0FBQyxZQUFZLENBQUM7WUFDeEMsS0FBSztZQUNMLFdBQVcsRUFBRSxPQUFPLENBQUMsV0FBVztZQUNoQyxxQkFBcUIsRUFBRSxJQUFJO1lBQzNCLFFBQVEsRUFBRSxNQUFNO1NBQ2pCLENBQUMsQ0FBQztRQUNILE9BQU8sZUFBZSxDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsQ0FBQztJQUN0QyxDQUFDO0lBQ0QsTUFBTSxVQUFVLEdBQUcsSUFBSSxPQUFPLENBQUMsMkJBQTJCLENBQUMsS0FBSyxFQUFFLFdBQVcsQ0FBQyxDQUFDO0lBQy9FLDZGQUE2RjtJQUM3RixnR0FBZ0c7SUFDaEcsTUFBTSxhQUFhLEdBQUcsT0FBTyxDQUFDLGlCQUFpQixDQUFDO1FBQzlDLEtBQUs7UUFDTCxVQUFVLEVBQUU7WUFDVixZQUFZLEVBQUUsS0FBSyxFQUFFLEVBQUUsVUFBVSxFQUFFLE1BQU0sRUFBRSxFQUFFLEVBQUU7Z0JBQzdDLFVBQVUsQ0FBQyxLQUFLLEVBQUUsQ0FBQztnQkFDbkIsSUFBSSxDQUFDO29CQUNILE9BQU8sTUFBTSxVQUFVLEVBQUUsQ0FBQztnQkFDNUIsQ0FBQztnQkFBQyxPQUFPLEtBQUssRUFBRSxDQUFDO29CQUNmLFVBQVUsQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFDLFdBQVcsRUFBRSxPQUFPLENBQUMsQ0FBQyxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUMsUUFBUSxDQUFDLENBQUM7b0JBQ3RFLE1BQU0sS0FBSyxDQUFDO2dCQUNkLENBQUM7WUFDSCxDQUFDO1NBQ0Y7S0FDRixDQUFDLENBQUM7SUFDSCxJQUFJLENBQUM7UUFDSCxNQUFNLE1BQU0sR0FBRyxNQUFNLE9BQU8sQ0FBQyxZQUFZLENBQUM7WUFDeEMsS0FBSyxFQUFFLGFBQWE7WUFDcEIsV0FBVyxFQUFFLE9BQU8sQ0FBQyxXQUFXO1lBQ2hDLHFCQUFxQixFQUFFLElBQUk7WUFDM0IsUUFBUSxFQUFFLE1BQU07WUFDaEIsc0JBQXNCLEVBQUUsQ0FBQyxFQUFFLE9BQU8sRUFBRSxLQUFLLEVBQUUsRUFBRSxFQUFFO2dCQUM3QyxVQUFVLENBQUMsR0FBRyxDQUFDLE9BQU8sRUFBRSxLQUFLLENBQUMsQ0FBQztZQUNqQyxDQUFDO1NBQ0YsQ0FBQyxDQUFDO1FBQ0gsVUFBVSxDQUFDLE1BQU0sQ0FBQyxTQUFTLENBQUMsQ0FBQztRQUM3QixPQUFPLGVBQWUsQ0FBQyxNQUFNLENBQUMsSUFBSSxDQUFDLENBQUM7SUFDdEMsQ0FBQztJQUFDLE9BQU8sS0FBSyxFQUFFLENBQUM7UUFDZixVQUFVLENBQUMsTUFBTSxDQUFDLE9BQU8sQ0FBQyxXQUFXLEVBQUUsT0FBTyxDQUFDLENBQUMsQ0FBQyxTQUFTLENBQUMsQ0FBQyxDQUFDLFFBQVEsQ0FBQyxDQUFDO1FBQ3ZFLE1BQU0sS0FBSyxDQUFDO0lBQ2QsQ0FBQztBQUNILENBQUM7QUFFRCxTQUFTLGVBQWUsQ0FBQyxPQUFlO0lBQ3RDLE9BQU87UUFDTDtZQUNFLElBQUksRUFBRSxNQUFNO1lBQ1osT0FBTyxFQUFFLHNDQUFzQyxPQUFPLDJDQUEyQztTQUNsRztLQUNGLENBQUM7QUFDSixDQUFDIn0=
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "author": "Task Venture Capital GmbH",
3
3
  "license": "MIT",
4
4
  "name": "@modelprofile.com/flexharness",
5
- "version": "8.2.0",
5
+ "version": "8.4.0",
6
6
  "type": "module",
7
7
  "description": "Managed agent sessions with tools, MCP, chat, OCR, documents and browser control through focused subpath imports.",
8
8
  "main": "./dist_ts/index.js",
@@ -62,8 +62,8 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@modelprofile.com/flexharness-agent": "8.2.0",
66
- "@modelprofile.com/flexharness-models": "8.2.0",
65
+ "@modelprofile.com/flexharness-agent": "8.4.0",
66
+ "@modelprofile.com/flexharness-models": "8.4.0",
67
67
  "@push.rocks/smartrequest": "^5.0.1",
68
68
  "ai": "^7.0.79",
69
69
  "zod": "^4.4.1"
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/flexharness',
6
- version: '8.2.0',
6
+ version: '8.4.0',
7
7
  description: 'The FlexHarness toolbox source repository and shared dependency catalog.'
8
8
  }
@@ -16,6 +16,7 @@ import {
16
16
  FlexHarnessStoreCommitUncertainError,
17
17
  FlexHarnessValidationError,
18
18
  errorToInfo,
19
+ modelLimitErrorInfo,
19
20
  } from './errors.js';
20
21
  import {
21
22
  FLEX_PROJECT_MANAGEMENT_LIMITS,
@@ -79,6 +80,7 @@ import type {
79
80
  IFlexSchedulePromptOptions,
80
81
  IFlexScopeSnapshot,
81
82
  IFlexSession,
83
+ IFlexSessionActivity,
82
84
  IFlexSessionGeneration,
83
85
  IFlexSessionGenerationCohortEntry,
84
86
  IFlexSessionReversionGroup,
@@ -97,6 +99,7 @@ import type {
97
99
  IFlexUndoSessionResult,
98
100
  IFlexUpdateSessionOptions,
99
101
  IFlexUpdateProjectTaskInput,
102
+ IFlexUsage,
100
103
  IJsonObject,
101
104
  TFlexAgentToolCallUpdateEvent,
102
105
  TFlexAgentToolCallFinishEvent,
@@ -335,6 +338,8 @@ interface IActiveRun {
335
338
  callbackParts: TFlexMessagePart[];
336
339
  partTextUtf8Bytes: Map<string, number>;
337
340
  callbacksClosed: boolean;
341
+ /** The usage the provider reported for the run's model calls so far. */
342
+ usage: IFlexUsage;
338
343
  reasoningPartIds: Map<string, string>;
339
344
  toolPartIds: Map<string, string>;
340
345
  trustedToolExecutionErrors: Map<string, Error>;
@@ -658,6 +663,14 @@ function combineErrors(errors: unknown[]): unknown {
658
663
  return Object.freeze(combined);
659
664
  }
660
665
 
666
+ /** The error and model limit a finished run leaves on its session activity. */
667
+ function terminalActivityError(message: IFlexMessage): Pick<IFlexSessionActivity, 'error' | 'limit'> {
668
+ return {
669
+ ...(message.error ? { error: message.error } : {}),
670
+ ...(message.limit ? { limit: message.limit } : {}),
671
+ };
672
+ }
673
+
661
674
  function isAbortError(error: unknown): boolean {
662
675
  return error instanceof FlexHarnessAbortError
663
676
  || (error instanceof DOMException && error.name === 'AbortError')
@@ -3386,6 +3399,13 @@ export class FlexHarness<TScope = unknown> {
3386
3399
  callbackParts: [],
3387
3400
  partTextUtf8Bytes: new Map(),
3388
3401
  callbacksClosed: false,
3402
+ usage: {
3403
+ inputTokens: 0,
3404
+ outputTokens: 0,
3405
+ totalTokens: 0,
3406
+ cacheReadTokens: 0,
3407
+ cacheWriteTokens: 0,
3408
+ },
3389
3409
  reasoningPartIds: new Map(),
3390
3410
  toolPartIds: new Map(),
3391
3411
  trustedToolExecutionErrors: new Map(),
@@ -3947,6 +3967,8 @@ export class FlexHarness<TScope = unknown> {
3947
3967
  ): Promise<IFlexPromptResult> {
3948
3968
  let generated = false;
3949
3969
  try {
3970
+ // The transactional generation settles only after its model calls are reported, so
3971
+ // `run.usage` is complete once it settles, also for a cancelled run.
3950
3972
  const rawResult = await generation;
3951
3973
  generated = true;
3952
3974
  if (run.callbackError) throw run.callbackError;
@@ -5676,9 +5698,13 @@ export class FlexHarness<TScope = unknown> {
5676
5698
  }
5677
5699
  assistantMessage.usage = cloneSerializable(result.usage);
5678
5700
  } else {
5701
+ assistantMessage.usage = cloneSerializable(run.usage);
5679
5702
  const message = truncateUtf8(error?.message ?? externalErrorFallback.message, maxTransferMetadataBytes);
5680
- userMessage.error = message;
5681
- assistantMessage.error = message;
5703
+ const limit = error instanceof FlexHarnessExternalError ? error.limit : undefined;
5704
+ for (const failedMessage of [userMessage, assistantMessage]) {
5705
+ failedMessage.error = message;
5706
+ if (limit) failedMessage.limit = cloneSerializable(limit);
5707
+ }
5682
5708
  }
5683
5709
  return {
5684
5710
  runId: run.runId,
@@ -5692,7 +5718,7 @@ export class FlexHarness<TScope = unknown> {
5692
5718
  finishReason: result.finishReason,
5693
5719
  steps: result.steps,
5694
5720
  }
5695
- : {}),
5721
+ : { usage: cloneSerializable(run.usage) }),
5696
5722
  };
5697
5723
  }
5698
5724
 
@@ -6766,7 +6792,7 @@ export class FlexHarness<TScope = unknown> {
6766
6792
  status: activityStatus,
6767
6793
  startedAt: run.stored.session.activity.startedAt,
6768
6794
  completedAt: timestamp,
6769
- ...(terminal.assistantMessage.error ? { error: terminal.assistantMessage.error } : {}),
6795
+ ...terminalActivityError(terminal.assistantMessage),
6770
6796
  };
6771
6797
  run.stored.session.updatedAt = timestamp;
6772
6798
  }, true);
@@ -7025,6 +7051,63 @@ export class FlexHarness<TScope = unknown> {
7025
7051
  }
7026
7052
  }
7027
7053
 
7054
+ private onRetry(state: IStorageState, sessionId: string, retry: plugins.IAgentRetryEvent): void {
7055
+ const run = this.callbackRun(state, sessionId);
7056
+ if (!run || !this.reserveCallbackCapacity(run, 1, 0, 0)) return;
7057
+ this.emitEvent(run.scopeId, run.stored.session, {
7058
+ type: 'run.retrying',
7059
+ runId: run.runId,
7060
+ attempt: retry.attempt,
7061
+ maxAttempts: retry.maxAttempts,
7062
+ delayMs: retry.delayMs,
7063
+ reason: retry.reason,
7064
+ });
7065
+ }
7066
+
7067
+ /**
7068
+ * Emits the usage of every model call and adds reported usage to its run. A run executes a
7069
+ * transactional generation, and `AgentSession.generate()` settles a transactional generation only
7070
+ * after it has reported each of its model calls, also when it is aborted. `executeRun()` awaits
7071
+ * that settlement before it builds the terminal, so the run is still active for each of its
7072
+ * events: they all reach `run.usage` and precede its `run.finished`. The events bypass the
7073
+ * callback limits and a failed callback: their number is bounded by the run's model calls, and
7074
+ * the usage of a run that exhausted its callback budget or failed in a callback must not be lost.
7075
+ */
7076
+ private onModelCallUsage(
7077
+ state: IStorageState,
7078
+ stored: IStoredSessionState,
7079
+ event: plugins.TAgentModelCallUsageEvent,
7080
+ ): void {
7081
+ const { source, generationId, ...call } = event;
7082
+ const sessionId = stored.session.sessionId;
7083
+ const activeRun = state.activeRuns.get(sessionId);
7084
+ const run = generationId !== undefined && activeRun?.runId === generationId ? activeRun : undefined;
7085
+ let scopeId = run?.scopeId;
7086
+ if (scopeId === undefined) {
7087
+ const ambientContext = this.compactorInvocationContext.getStore();
7088
+ const invocationContext = ambientContext
7089
+ ? this.deferredCompactorContexts.get(ambientContext) ?? ambientContext
7090
+ : undefined;
7091
+ if (
7092
+ !invocationContext
7093
+ || invocationContext.storageKey !== state.storageKey
7094
+ || invocationContext.sessionId !== sessionId
7095
+ ) {
7096
+ throw new Error('Model call usage is missing its exact FlexHarness invocation context.');
7097
+ }
7098
+ scopeId = invocationContext.scopeId;
7099
+ }
7100
+ if (run && call.status === 'reported') {
7101
+ for (const key of Object.keys(run.usage) as (keyof IFlexUsage)[]) run.usage[key] += call.usage[key];
7102
+ }
7103
+ this.emitEvent(scopeId, stored.session, {
7104
+ type: 'model.usage',
7105
+ ...(generationId === undefined ? {} : { runId: generationId }),
7106
+ source,
7107
+ ...call,
7108
+ });
7109
+ }
7110
+
7028
7111
  private callbackRun(state: IStorageState, sessionId: string): IActiveRun | undefined {
7029
7112
  const run = state.activeRuns.get(sessionId);
7030
7113
  return !run || run.callbacksClosed || run.callbackError ? undefined : run;
@@ -7777,7 +7860,8 @@ export class FlexHarness<TScope = unknown> {
7777
7860
  if ((typeof error === 'object' && error !== null) && this.trustedInternalErrors.has(error)) {
7778
7861
  return error as Error;
7779
7862
  }
7780
- let info = externalErrorFallback;
7863
+ const defaultInfo = modelLimitErrorInfo(error) ?? externalErrorFallback;
7864
+ let info = defaultInfo;
7781
7865
  if (this.externalErrorProjector) {
7782
7866
  try {
7783
7867
  info = validateProjectedErrorInfo(this.externalErrorProjector(error, {
@@ -7787,7 +7871,7 @@ export class FlexHarness<TScope = unknown> {
7787
7871
  runId,
7788
7872
  }));
7789
7873
  } catch {
7790
- info = externalErrorFallback;
7874
+ info = defaultInfo;
7791
7875
  }
7792
7876
  }
7793
7877
  return this.trustInternalError(new FlexHarnessExternalError(info));
@@ -8233,6 +8317,8 @@ export class FlexHarness<TScope = unknown> {
8233
8317
  onToolCallStart: (event) => this.onToolStart(state, sessionId, event),
8234
8318
  onToolCallUpdate: (event) => this.onToolUpdate(state, sessionId, event),
8235
8319
  onToolCallFinish: (event) => this.onToolFinish(state, sessionId, event),
8320
+ onRetry: (event) => this.onRetry(state, sessionId, event),
8321
+ onUsage: (event) => this.onModelCallUsage(state, stored, event),
8236
8322
  };
8237
8323
  stored.agentSession = await this.withCompactorContext(
8238
8324
  compactorContext,
@@ -8405,9 +8491,7 @@ export class FlexHarness<TScope = unknown> {
8405
8491
  runId: latestTerminal.runId,
8406
8492
  status: latestTerminal.status === 'completed' ? 'idle' : latestTerminal.status,
8407
8493
  completedAt,
8408
- ...(latestTerminal.assistantMessage.error
8409
- ? { error: latestTerminal.assistantMessage.error }
8410
- : {}),
8494
+ ...terminalActivityError(latestTerminal.assistantMessage),
8411
8495
  };
8412
8496
  stored.session.updatedAt = completedAt;
8413
8497
  } else if (['scheduled', 'running', 'waiting_permission'].includes(stored.session.status)) {
@@ -8490,7 +8574,11 @@ export class FlexHarness<TScope = unknown> {
8490
8574
  const message = status === 'cancelled' ? repairCancellationMessage : externalErrorFallback.message;
8491
8575
  userMessage.error = message;
8492
8576
  assistantMessage.error = message;
8493
- delete assistantMessage.usage;
8577
+ if (stage?.usage) {
8578
+ assistantMessage.usage = cloneSerializable(stage.usage);
8579
+ } else {
8580
+ delete assistantMessage.usage;
8581
+ }
8494
8582
  }
8495
8583
  return {
8496
8584
  runId,
@@ -8498,7 +8586,7 @@ export class FlexHarness<TScope = unknown> {
8498
8586
  userMessage,
8499
8587
  assistantMessage,
8500
8588
  ...(status === 'completed' && stage?.model ? { model: stage.model } : {}),
8501
- ...(status === 'completed' && stage?.usage ? { usage: stage.usage } : {}),
8589
+ ...(stage?.usage ? { usage: stage.usage } : {}),
8502
8590
  ...(status === 'completed' && stage?.finishReason ? { finishReason: stage.finishReason } : {}),
8503
8591
  ...(status === 'completed' && stage?.steps !== undefined ? { steps: stage.steps } : {}),
8504
8592
  };
package/ts/errors.ts CHANGED
@@ -1,4 +1,10 @@
1
- import type { IFlexErrorInfo } from './interfaces.js';
1
+ import * as plugins from './plugins.js';
2
+ import type { IFlexErrorInfo, TFlexModelLimit } from './interfaces.js';
3
+
4
+ const modelLimitErrorCodes: Record<TFlexModelLimit['kind'], string> = {
5
+ usage_limit: 'FLEX_USAGE_LIMIT',
6
+ rate_limit: 'FLEX_RATE_LIMIT',
7
+ };
2
8
 
3
9
  export class FlexHarnessError extends Error {
4
10
  public readonly code: string;
@@ -85,9 +91,12 @@ export class FlexHarnessCallbackOverflowError extends FlexHarnessError {
85
91
  }
86
92
 
87
93
  export class FlexHarnessExternalError extends FlexHarnessError {
94
+ public readonly limit: TFlexModelLimit | undefined;
95
+
88
96
  constructor(info: IFlexErrorInfo) {
89
97
  super(info.code ?? 'FLEX_EXTERNAL_ERROR', info.message);
90
98
  this.name = info.name;
99
+ this.limit = info.limit;
91
100
  }
92
101
  }
93
102
 
@@ -164,6 +173,7 @@ export function errorToInfo(error: unknown): IFlexErrorInfo {
164
173
  name: error.name,
165
174
  message: error.message,
166
175
  code: error.code,
176
+ ...(error instanceof FlexHarnessExternalError && error.limit ? { limit: error.limit } : {}),
167
177
  };
168
178
  }
169
179
  if (error instanceof Error) {
@@ -179,6 +189,17 @@ export function errorToInfo(error: unknown): IFlexErrorInfo {
179
189
  };
180
190
  }
181
191
 
192
+ /** The public projection of a model limit error, which names the limit and its reset time. */
193
+ export function modelLimitErrorInfo(error: unknown): IFlexErrorInfo | undefined {
194
+ if (!plugins.isModelLimitError(error)) return undefined;
195
+ return {
196
+ name: 'FlexHarnessExternalError',
197
+ message: error.message,
198
+ code: modelLimitErrorCodes[error.limit.kind],
199
+ limit: error.limit,
200
+ };
201
+ }
202
+
182
203
  export function errorMessage(error: unknown): string {
183
204
  return error instanceof Error ? error.message : String(error);
184
205
  }
package/ts/interfaces.ts CHANGED
@@ -22,6 +22,15 @@ export type TFlexAgentToolCallUpdateEvent = Parameters<
22
22
  export type TFlexAgentToolCallFinishEvent = Parameters<
23
23
  NonNullable<TFlexAgentSessionOptions['onToolCallFinish']>
24
24
  >[0];
25
+ /** A usage or rate limit a model provider reported; `resetsAt` is in epoch milliseconds. */
26
+ export type TFlexModelLimit = plugins.IModelLimitInfo;
27
+ export type TFlexRetryReason = plugins.TAgentRetryReason;
28
+ /**
29
+ * The usage outcome of one model call: `reported` with the provider's `usage` and
30
+ * `responseModelId`, or `unreported` with a `reason` (`aborted`, `failed` or `missing`).
31
+ * Every call carries `provider` and `requestedModelId`; key usage caps on those two.
32
+ */
33
+ export type TFlexModelCallUsage = plugins.TAgentModelCallUsage;
25
34
 
26
35
  export interface IFlexTextPromptPart {
27
36
  type: 'text';
@@ -123,8 +132,15 @@ export interface IFlexMessage {
123
132
  completedAt?: string;
124
133
  parts: TFlexMessagePart[];
125
134
  model?: IFlexModelIdentity;
135
+ /**
136
+ * On a terminal assistant message: the usage the provider reported for the run's model calls.
137
+ * For a failed or cancelled run it is a lower bound when a `model.usage` event of the run was
138
+ * `unreported`.
139
+ */
126
140
  usage?: IFlexUsage;
127
141
  error?: string;
142
+ /** The model limit that failed this run, next to its error. */
143
+ limit?: TFlexModelLimit;
128
144
  }
129
145
 
130
146
  export interface IFlexSessionActivity {
@@ -133,6 +149,8 @@ export interface IFlexSessionActivity {
133
149
  startedAt?: string;
134
150
  completedAt?: string;
135
151
  error?: string;
152
+ /** The model limit that failed the last run, next to its error. */
153
+ limit?: TFlexModelLimit;
136
154
  }
137
155
 
138
156
  export interface IFlexSession {
@@ -457,6 +475,7 @@ export interface IFlexTerminalProjection {
457
475
  userMessage: IFlexMessage;
458
476
  assistantMessage: IFlexMessage;
459
477
  model?: IFlexModelIdentity;
478
+ /** The usage the provider reported for the run's model calls, whatever the status. */
460
479
  usage?: IFlexUsage;
461
480
  finishReason?: string;
462
481
  steps?: number;
@@ -1227,6 +1246,28 @@ export interface IFlexRunFinishedEvent extends IFlexEventBase {
1227
1246
  readonly error?: IFlexErrorInfo;
1228
1247
  }
1229
1248
 
1249
+ /** The run waits `delayMs` before retrying a rate-limited or unavailable model call. */
1250
+ export interface IFlexRunRetryingEvent extends IFlexEventBase {
1251
+ readonly type: 'run.retrying';
1252
+ readonly runId: string;
1253
+ readonly attempt: number;
1254
+ readonly maxAttempts: number;
1255
+ readonly delayMs: number;
1256
+ readonly reason: TFlexRetryReason;
1257
+ }
1258
+
1259
+ /**
1260
+ * The usage outcome of one model call of a session, emitted once per call whatever the run's
1261
+ * outcome. `source: 'generation'` is a model step of the run `runId`. `source: 'compaction'` is a
1262
+ * model call of a context compaction the context compactor reported; `runId` names the run whose
1263
+ * context overflow caused it and is absent for `compactSession()` and event retention.
1264
+ */
1265
+ export type TFlexModelUsageEvent = IFlexEventBase & {
1266
+ readonly type: 'model.usage';
1267
+ readonly runId?: string;
1268
+ readonly source: 'generation' | 'compaction';
1269
+ } & Readonly<TFlexModelCallUsage>;
1270
+
1230
1271
  export interface IFlexPromptQueueEvent extends IFlexEventBase {
1231
1272
  readonly type: 'prompt.queued' | 'prompt.started' | 'prompt.running' | 'prompt.finished';
1232
1273
  readonly queueId: string;
@@ -1244,6 +1285,8 @@ export interface IFlexErrorInfo {
1244
1285
  name: string;
1245
1286
  message: string;
1246
1287
  code?: string;
1288
+ /** Set when a model usage or rate limit caused the error. */
1289
+ limit?: TFlexModelLimit;
1247
1290
  }
1248
1291
 
1249
1292
  export type TFlexHarnessEvent =
@@ -1256,6 +1299,8 @@ export type TFlexHarnessEvent =
1256
1299
  | IFlexPermissionRequestedEvent
1257
1300
  | IFlexPermissionResolvedEvent
1258
1301
  | IFlexRunFinishedEvent
1302
+ | IFlexRunRetryingEvent
1303
+ | TFlexModelUsageEvent
1259
1304
  | IFlexPromptQueueEvent
1260
1305
  | IFlexSessionHistoryChangedEvent;
1261
1306
 
package/ts/plugins.ts CHANGED
@@ -44,6 +44,7 @@ export type {
44
44
  IAgentGenerateResult,
45
45
  IAgentGenerationHandle,
46
46
  IAgentGenerationLease,
47
+ IAgentRetryEvent,
47
48
  IAgentRunResult,
48
49
  IAgentSession,
49
50
  IAgentSessionOptions,
@@ -62,6 +63,14 @@ export type {
62
63
  TAgentContextCompactor,
63
64
  TAgentEvent,
64
65
  TAgentGenerationOutcome,
66
+ TAgentModelCallUsage,
67
+ TAgentModelCallUsageEvent,
68
+ TAgentRetryReason,
65
69
  TAgentToolCallFinishEvent,
66
70
  TAgentToolExecutionReconciliationOptions,
67
71
  } from '@modelprofile.com/flexharness-agent';
72
+
73
+ import { isModelLimitError, isModelLimitInfo } from '@modelprofile.com/flexharness-models';
74
+
75
+ export { isModelLimitError, isModelLimitInfo };
76
+ export type { IModelLimitInfo } from '@modelprofile.com/flexharness-models';
package/ts/utils.json.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as plugins from './plugins.js';
1
2
  import { FlexHarnessStoreFormatError, FlexHarnessValidationError } from './errors.js';
2
3
  import {
3
4
  FLEX_REVERSION_MAX_AFFECTED_WORKSPACES,
@@ -406,6 +407,12 @@ function requireOptionalString(value: unknown, path: string): string | undefined
406
407
  return value === undefined ? undefined : requireString(value, path);
407
408
  }
408
409
 
410
+ function requireOptionalModelLimit(value: unknown, path: string): void {
411
+ if (value !== undefined && !plugins.isModelLimitInfo(value)) {
412
+ throw new FlexHarnessStoreFormatError(`${path} is invalid.`);
413
+ }
414
+ }
415
+
409
416
  function requireNonNegativeNumber(value: unknown, path: string): void {
410
417
  if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
411
418
  throw new FlexHarnessStoreFormatError(`${path} must be a non-negative finite number.`);
@@ -501,7 +508,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
501
508
  throw new FlexHarnessStoreFormatError(`${path}.status is invalid.`);
502
509
  }
503
510
  const activity = requireRecord(session.activity, `${path}.activity`);
504
- requireOnlyKeys(activity, ['runId', 'status', 'startedAt', 'completedAt', 'error'], `${path}.activity`);
511
+ requireOnlyKeys(activity, ['runId', 'status', 'startedAt', 'completedAt', 'error', 'limit'], `${path}.activity`);
505
512
  if (!activityStatuses.includes(activity.status as (typeof activityStatuses)[number])) {
506
513
  throw new FlexHarnessStoreFormatError(`${path}.activity.status is invalid.`);
507
514
  }
@@ -509,6 +516,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
509
516
  requireOptionalString(activity.startedAt, `${path}.activity.startedAt`);
510
517
  requireOptionalString(activity.completedAt, `${path}.activity.completedAt`);
511
518
  requireOptionalString(activity.error, `${path}.activity.error`);
519
+ requireOptionalModelLimit(activity.limit, `${path}.activity.limit`);
512
520
  const relationship = [
513
521
  session.parentSessionId,
514
522
  session.parentRunId,
@@ -667,6 +675,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
667
675
  'model',
668
676
  'usage',
669
677
  'error',
678
+ 'limit',
670
679
  ],
671
680
  path,
672
681
  );
@@ -682,6 +691,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
682
691
  requireString(message.createdAt, `${path}.createdAt`);
683
692
  requireOptionalString(message.completedAt, `${path}.completedAt`);
684
693
  requireOptionalString(message.error, `${path}.error`);
694
+ requireOptionalModelLimit(message.limit, `${path}.limit`);
685
695
  if (message.model !== undefined) validateModel(message.model, `${path}.model`);
686
696
  if (message.usage !== undefined) validateUsage(message.usage, `${path}.usage`);
687
697
  if (!Array.isArray(message.parts)) {
@@ -1,3 +1,4 @@
1
+ import * as plugins from './plugins.js';
1
2
  import { FlexHarnessValidationError } from './errors.js';
2
3
  import {
3
4
  FLEX_PROJECT_MANAGEMENT_LIMITS,
@@ -162,30 +163,30 @@ export function validateDeleteSessionGenerationCohortInput(
162
163
  return { root, authorizedBySessionId };
163
164
  }
164
165
 
165
- export function validateProjectedErrorInfo(value: unknown): IFlexErrorInfo {
166
+ /** A plain object whose own properties are all string-keyed data properties, so reading them runs no code. */
167
+ function isPlainDataObject(value: unknown): value is Record<string, unknown> {
166
168
  if (
167
169
  !value
168
170
  || typeof value !== 'object'
169
171
  || Array.isArray(value)
170
172
  || ![Object.prototype, null].includes(Object.getPrototypeOf(value))
171
- ) {
173
+ ) return false;
174
+ return Reflect.ownKeys(value).every((key) => {
175
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
176
+ return typeof key === 'string' && descriptor !== undefined && 'value' in descriptor;
177
+ });
178
+ }
179
+
180
+ export function validateProjectedErrorInfo(value: unknown): IFlexErrorInfo {
181
+ if (!isPlainDataObject(value)) {
172
182
  throw new FlexHarnessValidationError('External error projection is invalid.');
173
183
  }
174
- const descriptors = Object.getOwnPropertyDescriptors(value);
175
- const unsupportedKey = Reflect.ownKeys(descriptors).find(
176
- (key) => typeof key !== 'string' || !['name', 'message', 'code'].includes(key),
177
- );
178
- const name = descriptors.name?.value;
179
- const message = descriptors.message?.value;
180
- const code = descriptors.code?.value;
184
+ const unsupportedKey = Object.getOwnPropertyNames(value)
185
+ .find((key) => !['name', 'message', 'code', 'limit'].includes(key));
186
+ const { name, message, code, limit } = value;
181
187
  if (
182
188
  unsupportedKey
183
- || descriptors.name?.get !== undefined
184
- || descriptors.name?.set !== undefined
185
- || descriptors.message?.get !== undefined
186
- || descriptors.message?.set !== undefined
187
- || descriptors.code?.get !== undefined
188
- || descriptors.code?.set !== undefined
189
+ || (limit !== undefined && !(isPlainDataObject(limit) && plugins.isModelLimitInfo(limit)))
189
190
  || typeof name !== 'string'
190
191
  || !name
191
192
  || Buffer.byteLength(name, 'utf8') > maxProjectedErrorNameBytes
@@ -199,7 +200,12 @@ export function validateProjectedErrorInfo(value: unknown): IFlexErrorInfo {
199
200
  ) {
200
201
  throw new FlexHarnessValidationError('External error projection is invalid.');
201
202
  }
202
- return Object.freeze({ name, message, ...(code === undefined ? {} : { code }) });
203
+ return Object.freeze({
204
+ name,
205
+ message,
206
+ ...(code === undefined ? {} : { code }),
207
+ ...(limit === undefined ? {} : { limit: Object.freeze({ ...limit }) }),
208
+ });
203
209
  }
204
210
 
205
211
  export function validateUpdateSessionOptions(options: IFlexUpdateSessionOptions): void {
@@ -1,6 +1,12 @@
1
- import { generateText } from 'ai';
1
+ import { generateText, wrapLanguageModel } from 'ai';
2
2
 
3
- export { generateText };
3
+ export { generateText, wrapLanguageModel };
4
4
 
5
5
  export type { ModelMessage } from 'ai';
6
6
  export type { TSmartAiLanguageModel } from '@modelprofile.com/flexharness-models';
7
+
8
+ import { AgentModelCallUsageRecorder } from '@modelprofile.com/flexharness-agent';
9
+
10
+ export { AgentModelCallUsageRecorder };
11
+
12
+ export type { TAgentModelCallUsageReporter } from '@modelprofile.com/flexharness-agent';