@modelprofile.com/flexharness 9.4.0 → 9.6.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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.d.ts +33 -1
- package/dist_ts/classes.flexharness.js +415 -22
- package/dist_ts/errors.d.ts +5 -0
- package/dist_ts/errors.js +10 -1
- package/dist_ts/interfaces.d.ts +115 -0
- package/dist_ts/interfaces.js +19 -1
- package/dist_ts/utils.json.d.ts +5 -0
- package/dist_ts/utils.json.js +38 -1
- package/dist_ts/utils.sessionimport.d.ts +26 -0
- package/dist_ts/utils.sessionimport.js +241 -0
- package/package.json +3 -3
- package/readme.md +86 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.ts +492 -18
- package/ts/errors.ts +11 -0
- package/ts/interfaces.ts +130 -0
- package/ts/readme.md +86 -3
- package/ts/utils.json.ts +41 -0
- package/ts/utils.sessionimport.ts +327 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { FlexHarnessValidationError } from './errors.js';
|
|
2
|
+
import {
|
|
3
|
+
FLEX_PROJECT_MANAGEMENT_LIMITS,
|
|
4
|
+
FLEX_SESSION_IMPORT_DEFAULT_LIMITS,
|
|
5
|
+
FLEX_SESSION_IMPORT_MAXIMUM_LIMITS,
|
|
6
|
+
FLEX_SESSION_IMPORT_MAX_DETAILS_BYTES,
|
|
7
|
+
FLEX_SESSION_IMPORT_MAX_MESSAGE_BYTES,
|
|
8
|
+
} from './interfaces.js';
|
|
9
|
+
import type {
|
|
10
|
+
IFlexBeginSessionImportOptions,
|
|
11
|
+
IFlexImportedRun,
|
|
12
|
+
IFlexMessage,
|
|
13
|
+
IFlexSessionImportHandle,
|
|
14
|
+
IFlexSessionImportLimits,
|
|
15
|
+
IFlexSessionImportParent,
|
|
16
|
+
IFlexSessionImportPart,
|
|
17
|
+
IFlexSessionImportSource,
|
|
18
|
+
IJsonObject,
|
|
19
|
+
} from './interfaces.js';
|
|
20
|
+
import { maxSubagentNameBytes } from './utils.harnessoptions.js';
|
|
21
|
+
import { assertFlexMessageRecord, assertJsonSerializable, cloneSerializable } from './utils.json.js';
|
|
22
|
+
import {
|
|
23
|
+
createBoundedTransferMessage,
|
|
24
|
+
jsonBytes,
|
|
25
|
+
maxTransferIdentifierBytes,
|
|
26
|
+
requireTransferIdentifier,
|
|
27
|
+
} from './utils.transfer.js';
|
|
28
|
+
import { validateIdentifier, validateUtf8String } from './utils.validation.js';
|
|
29
|
+
|
|
30
|
+
const maxSourceBytes = 128;
|
|
31
|
+
const maxDigestBytes = 256;
|
|
32
|
+
const maxImportParts = 1024;
|
|
33
|
+
const controlCharacterPattern = /[\u0000-\u001f\u007f]/u;
|
|
34
|
+
|
|
35
|
+
export interface INormalizedSessionImportOptions {
|
|
36
|
+
sessionId: string;
|
|
37
|
+
title?: string;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
updatedAt: string;
|
|
40
|
+
origin: IFlexSessionImportSource;
|
|
41
|
+
parent?: IFlexSessionImportParent;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface INormalizedImportedRunPage {
|
|
45
|
+
runs: IFlexImportedRun[];
|
|
46
|
+
bytes: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveSessionImportLimits(
|
|
50
|
+
limits: IFlexSessionImportLimits = {},
|
|
51
|
+
): Required<IFlexSessionImportLimits> {
|
|
52
|
+
const resolved = {
|
|
53
|
+
maxOpenImports: limits.maxOpenImports ?? FLEX_SESSION_IMPORT_DEFAULT_LIMITS.maxOpenImports,
|
|
54
|
+
maxMessagesPerSession: limits.maxMessagesPerSession
|
|
55
|
+
?? FLEX_SESSION_IMPORT_DEFAULT_LIMITS.maxMessagesPerSession,
|
|
56
|
+
maxSessionBytes: limits.maxSessionBytes ?? FLEX_SESSION_IMPORT_DEFAULT_LIMITS.maxSessionBytes,
|
|
57
|
+
maxPageBytes: limits.maxPageBytes ?? FLEX_SESSION_IMPORT_DEFAULT_LIMITS.maxPageBytes,
|
|
58
|
+
stagedImportIdleTimeoutMs: limits.stagedImportIdleTimeoutMs
|
|
59
|
+
?? FLEX_SESSION_IMPORT_DEFAULT_LIMITS.stagedImportIdleTimeoutMs,
|
|
60
|
+
};
|
|
61
|
+
for (const [name, value] of Object.entries(resolved)) {
|
|
62
|
+
const maximum = FLEX_SESSION_IMPORT_MAXIMUM_LIMITS[name as keyof IFlexSessionImportLimits];
|
|
63
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
64
|
+
throw new FlexHarnessValidationError(
|
|
65
|
+
`sessionImportLimits.${name} must be an integer from 1 through ${maximum}.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return resolved;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function requirePlainRecord(value: unknown, name: string): Record<string, unknown> {
|
|
73
|
+
if (
|
|
74
|
+
!value
|
|
75
|
+
|| typeof value !== 'object'
|
|
76
|
+
|| Array.isArray(value)
|
|
77
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
78
|
+
) {
|
|
79
|
+
throw new FlexHarnessValidationError(`${name} must be a plain object.`);
|
|
80
|
+
}
|
|
81
|
+
return value as Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function requireKnownKeys(
|
|
85
|
+
record: Record<string, unknown>,
|
|
86
|
+
allowedKeys: readonly string[],
|
|
87
|
+
name: string,
|
|
88
|
+
): void {
|
|
89
|
+
const unsupported = Reflect.ownKeys(record).find((key) =>
|
|
90
|
+
typeof key !== 'string' || !allowedKeys.includes(key));
|
|
91
|
+
if (unsupported !== undefined) {
|
|
92
|
+
throw new FlexHarnessValidationError(`${name} does not support "${String(unsupported)}".`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requireBoundedText(value: unknown, name: string, maxBytes: number): string {
|
|
97
|
+
validateUtf8String(value, name, maxBytes, true);
|
|
98
|
+
if (controlCharacterPattern.test(value)) {
|
|
99
|
+
throw new FlexHarnessValidationError(`${name} must not contain control characters.`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A timestamp exactly as `Date.prototype.toISOString()` renders it. */
|
|
105
|
+
export function requireCanonicalTimestamp(value: unknown, name: string): string {
|
|
106
|
+
if (typeof value !== 'string' || value.length > 64) {
|
|
107
|
+
throw new FlexHarnessValidationError(`${name} must be an ISO 8601 timestamp.`);
|
|
108
|
+
}
|
|
109
|
+
const time = Date.parse(value);
|
|
110
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== value) {
|
|
111
|
+
throw new FlexHarnessValidationError(
|
|
112
|
+
`${name} must be an ISO 8601 timestamp as Date.prototype.toISOString() renders it.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeImportPart(value: unknown): IFlexSessionImportPart {
|
|
119
|
+
const part = requirePlainRecord(value, 'origin.part');
|
|
120
|
+
requireKnownKeys(part, ['index', 'count'], 'origin.part');
|
|
121
|
+
const { index, count } = part;
|
|
122
|
+
if (
|
|
123
|
+
!Number.isSafeInteger(count)
|
|
124
|
+
|| Number(count) < 1
|
|
125
|
+
|| Number(count) > maxImportParts
|
|
126
|
+
|| !Number.isSafeInteger(index)
|
|
127
|
+
|| Number(index) < 0
|
|
128
|
+
|| Number(index) >= Number(count)
|
|
129
|
+
) {
|
|
130
|
+
throw new FlexHarnessValidationError(
|
|
131
|
+
`origin.part must have a count from 1 through ${maxImportParts} and a zero-based index below it.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return { index: index as number, count: count as number };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeImportDetails(value: unknown): IJsonObject {
|
|
138
|
+
requirePlainRecord(value, 'origin.details');
|
|
139
|
+
try {
|
|
140
|
+
assertJsonSerializable(value, 'origin.details');
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new FlexHarnessValidationError('origin.details must be a JSON object.', { cause: error });
|
|
143
|
+
}
|
|
144
|
+
if (jsonBytes(value) > FLEX_SESSION_IMPORT_MAX_DETAILS_BYTES) {
|
|
145
|
+
throw new FlexHarnessValidationError(
|
|
146
|
+
`origin.details must be at most ${FLEX_SESSION_IMPORT_MAX_DETAILS_BYTES} JSON bytes.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return cloneSerializable(value as IJsonObject);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeImportSource(value: unknown): IFlexSessionImportSource {
|
|
153
|
+
const origin = requirePlainRecord(value, 'origin');
|
|
154
|
+
requireKnownKeys(origin, ['source', 'sourceId', 'digest', 'part', 'details'], 'origin');
|
|
155
|
+
return {
|
|
156
|
+
source: requireBoundedText(origin.source, 'origin.source', maxSourceBytes),
|
|
157
|
+
sourceId: requireBoundedText(origin.sourceId, 'origin.sourceId', maxTransferIdentifierBytes),
|
|
158
|
+
digest: requireBoundedText(origin.digest, 'origin.digest', maxDigestBytes),
|
|
159
|
+
...(origin.part === undefined ? {} : { part: normalizeImportPart(origin.part) }),
|
|
160
|
+
...(origin.details === undefined ? {} : { details: normalizeImportDetails(origin.details) }),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizeImportParent(value: unknown): IFlexSessionImportParent {
|
|
165
|
+
const parent = requirePlainRecord(value, 'parent');
|
|
166
|
+
requireKnownKeys(parent, ['sessionId', 'runId', 'toolCallId', 'agent'], 'parent');
|
|
167
|
+
return {
|
|
168
|
+
sessionId: requireBoundedText(parent.sessionId, 'parent.sessionId', maxTransferIdentifierBytes),
|
|
169
|
+
runId: requireBoundedText(parent.runId, 'parent.runId', maxTransferIdentifierBytes),
|
|
170
|
+
toolCallId: requireBoundedText(parent.toolCallId, 'parent.toolCallId', maxTransferIdentifierBytes),
|
|
171
|
+
agent: requireBoundedText(parent.agent, 'parent.agent', maxSubagentNameBytes),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function normalizeBeginSessionImportOptions(
|
|
176
|
+
options: IFlexBeginSessionImportOptions,
|
|
177
|
+
): INormalizedSessionImportOptions {
|
|
178
|
+
const record = requirePlainRecord(options, 'beginSessionImport options');
|
|
179
|
+
requireKnownKeys(
|
|
180
|
+
record,
|
|
181
|
+
['sessionId', 'title', 'createdAt', 'updatedAt', 'origin', 'parent'],
|
|
182
|
+
'beginSessionImport options',
|
|
183
|
+
);
|
|
184
|
+
const sessionId = record.sessionId as string;
|
|
185
|
+
validateIdentifier(sessionId, 'sessionId');
|
|
186
|
+
requireTransferIdentifier(sessionId, 'sessionId');
|
|
187
|
+
if (record.title !== undefined) {
|
|
188
|
+
validateUtf8String(record.title, 'title', FLEX_PROJECT_MANAGEMENT_LIMITS.maxTitleBytes, true);
|
|
189
|
+
}
|
|
190
|
+
const createdAt = requireCanonicalTimestamp(record.createdAt, 'createdAt');
|
|
191
|
+
const updatedAt = record.updatedAt === undefined
|
|
192
|
+
? createdAt
|
|
193
|
+
: requireCanonicalTimestamp(record.updatedAt, 'updatedAt');
|
|
194
|
+
if (Date.parse(updatedAt) < Date.parse(createdAt)) {
|
|
195
|
+
throw new FlexHarnessValidationError('updatedAt must not be earlier than createdAt.');
|
|
196
|
+
}
|
|
197
|
+
const parent = record.parent === undefined ? undefined : normalizeImportParent(record.parent);
|
|
198
|
+
if (parent?.sessionId === sessionId) {
|
|
199
|
+
throw new FlexHarnessValidationError('An imported session cannot be its own parent.');
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
sessionId,
|
|
203
|
+
...(record.title === undefined ? {} : { title: record.title as string }),
|
|
204
|
+
createdAt,
|
|
205
|
+
updatedAt,
|
|
206
|
+
origin: normalizeImportSource(record.origin),
|
|
207
|
+
...(parent === undefined ? {} : { parent }),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The identity an import is idempotent by: its source, source id and part. */
|
|
212
|
+
export function sessionImportIdentity(
|
|
213
|
+
origin: Pick<IFlexSessionImportSource, 'source' | 'sourceId' | 'part'>,
|
|
214
|
+
): string {
|
|
215
|
+
return JSON.stringify([origin.source, origin.sourceId, origin.part?.index ?? null]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeImportedMessage(
|
|
219
|
+
value: unknown,
|
|
220
|
+
path: string,
|
|
221
|
+
role: IFlexMessage['role'],
|
|
222
|
+
sessionId: string,
|
|
223
|
+
): { message: IFlexMessage; bytes: number } {
|
|
224
|
+
try {
|
|
225
|
+
assertFlexMessageRecord(value, path);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
throw new FlexHarnessValidationError(
|
|
228
|
+
`${path} is not a valid message: ${error instanceof Error ? error.message : String(error)}`,
|
|
229
|
+
{ cause: error },
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const message = cloneSerializable(value as IFlexMessage);
|
|
233
|
+
if (message.role !== role) {
|
|
234
|
+
throw new FlexHarnessValidationError(`${path}.role must be "${role}".`);
|
|
235
|
+
}
|
|
236
|
+
if (message.sessionId !== sessionId) {
|
|
237
|
+
throw new FlexHarnessValidationError(`${path}.sessionId must be the imported session "${sessionId}".`);
|
|
238
|
+
}
|
|
239
|
+
if (message.status === 'streaming') {
|
|
240
|
+
throw new FlexHarnessValidationError(`${path}.status must be completed, failed or cancelled.`);
|
|
241
|
+
}
|
|
242
|
+
requireCanonicalTimestamp(message.createdAt, `${path}.createdAt`);
|
|
243
|
+
if (message.completedAt !== undefined) {
|
|
244
|
+
requireCanonicalTimestamp(message.completedAt, `${path}.completedAt`);
|
|
245
|
+
}
|
|
246
|
+
for (const [index, part] of message.parts.entries()) {
|
|
247
|
+
if (part.type !== 'reasoning' && part.type !== 'tool') continue;
|
|
248
|
+
if (part.status === 'running') {
|
|
249
|
+
throw new FlexHarnessValidationError(`${path}.parts[${index}] must not be running.`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const bytes = jsonBytes(message);
|
|
253
|
+
if (bytes > FLEX_SESSION_IMPORT_MAX_MESSAGE_BYTES) {
|
|
254
|
+
throw new FlexHarnessValidationError(
|
|
255
|
+
`${path} exceeds ${FLEX_SESSION_IMPORT_MAX_MESSAGE_BYTES} JSON bytes.`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
createBoundedTransferMessage(message);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
throw new FlexHarnessValidationError(`${path} cannot be read back within the transfer limits.`, {
|
|
262
|
+
cause: error,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return { message, bytes };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Validates one page of imported runs. Every message must belong to `sessionId`, be terminal and
|
|
270
|
+
* fit the transfer limits; a run's user and assistant message share its run id and status.
|
|
271
|
+
*/
|
|
272
|
+
export function normalizeImportedRunPage(
|
|
273
|
+
runs: readonly IFlexImportedRun[],
|
|
274
|
+
sessionId: string,
|
|
275
|
+
maxPageBytes: number,
|
|
276
|
+
): INormalizedImportedRunPage {
|
|
277
|
+
if (!Array.isArray(runs) || runs.length === 0) {
|
|
278
|
+
throw new FlexHarnessValidationError('Imported runs must be a non-empty array.');
|
|
279
|
+
}
|
|
280
|
+
let pageBytes: number;
|
|
281
|
+
try {
|
|
282
|
+
assertJsonSerializable(runs, 'runs');
|
|
283
|
+
pageBytes = jsonBytes(runs);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
throw new FlexHarnessValidationError('Imported runs must be JSON-safe.', { cause: error });
|
|
286
|
+
}
|
|
287
|
+
if (pageBytes > maxPageBytes) {
|
|
288
|
+
throw new FlexHarnessValidationError(`Imported runs exceed the page limit of ${maxPageBytes} JSON bytes.`);
|
|
289
|
+
}
|
|
290
|
+
const normalized: IFlexImportedRun[] = [];
|
|
291
|
+
let bytes = 0;
|
|
292
|
+
for (const [index, value] of runs.entries()) {
|
|
293
|
+
const path = `runs[${index}]`;
|
|
294
|
+
const run = requirePlainRecord(value, path);
|
|
295
|
+
requireKnownKeys(run, ['user', 'assistant'], path);
|
|
296
|
+
const user = normalizeImportedMessage(run.user, `${path}.user`, 'user', sessionId);
|
|
297
|
+
const assistant = normalizeImportedMessage(run.assistant, `${path}.assistant`, 'assistant', sessionId);
|
|
298
|
+
if (user.message.runId !== assistant.message.runId) {
|
|
299
|
+
throw new FlexHarnessValidationError(`${path} user and assistant messages must share one runId.`);
|
|
300
|
+
}
|
|
301
|
+
if (user.message.messageId === assistant.message.messageId) {
|
|
302
|
+
throw new FlexHarnessValidationError(`${path} user and assistant messages must have distinct ids.`);
|
|
303
|
+
}
|
|
304
|
+
if (user.message.status !== assistant.message.status) {
|
|
305
|
+
throw new FlexHarnessValidationError(`${path} user and assistant messages must share one status.`);
|
|
306
|
+
}
|
|
307
|
+
normalized.push({ user: user.message, assistant: assistant.message });
|
|
308
|
+
bytes += user.bytes + assistant.bytes;
|
|
309
|
+
}
|
|
310
|
+
return { runs: normalized, bytes };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Validates the shape of a handle `beginSessionImport()` returned. */
|
|
314
|
+
export function validateSessionImportHandle(handle: IFlexSessionImportHandle): IFlexSessionImportHandle {
|
|
315
|
+
const record = requirePlainRecord(handle, 'Session import handle');
|
|
316
|
+
requireKnownKeys(record, ['importId', 'scopeId', 'sessionId'], 'Session import handle');
|
|
317
|
+
for (const key of ['importId', 'scopeId', 'sessionId'] as const) {
|
|
318
|
+
if (typeof record[key] !== 'string' || record[key] === '') {
|
|
319
|
+
throw new FlexHarnessValidationError(`Session import handle ${key} must be a non-empty string.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
importId: record.importId as string,
|
|
324
|
+
scopeId: record.scopeId as string,
|
|
325
|
+
sessionId: record.sessionId as string,
|
|
326
|
+
};
|
|
327
|
+
}
|