@modelprofile.com/flexharness 11.0.0 → 11.1.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.codesandbox.d.ts +12 -4
- package/dist_ts/classes.codesandbox.js +119 -287
- package/dist_ts/classes.codesandbox.worker.d.ts +52 -0
- package/dist_ts/classes.codesandbox.worker.js +310 -0
- package/dist_ts/classes.flexharness.callbacks.d.ts +37 -1
- package/dist_ts/classes.flexharness.callbacks.js +162 -53
- package/dist_ts/classes.flexharness.core.d.ts +2 -1
- package/dist_ts/classes.flexharness.core.js +22 -18
- package/dist_ts/classes.flexharness.results.js +4 -4
- package/dist_ts/classes.flexharness.reversions.d.ts +7 -0
- package/dist_ts/classes.flexharness.reversions.js +40 -39
- package/dist_ts/classes.flexharness.runs.js +7 -3
- package/dist_ts/classes.flexharness.tooldispatch.d.ts +2 -1
- package/dist_ts/classes.flexharness.tooldispatch.js +27 -9
- package/dist_ts/classes.stores.js +15 -13
- package/dist_ts/errors.d.ts +5 -0
- package/dist_ts/errors.js +10 -1
- package/dist_ts/index.d.ts +2 -1
- package/dist_ts/index.js +3 -2
- package/dist_ts/interfaces.d.ts +31 -9
- package/dist_ts/interfaces.flexharness.d.ts +14 -0
- package/dist_ts/interfaces.js +3 -3
- package/dist_ts/plugins.d.ts +6 -4
- package/dist_ts/plugins.js +10 -11
- package/dist_ts/utils.harnessoptions.d.ts +3 -1
- package/dist_ts/utils.harnessoptions.js +15 -2
- package/dist_ts/utils.json.d.ts +9 -1
- package/dist_ts/utils.json.js +92 -33
- package/dist_ts/utils.storecodecs.d.ts +1 -1
- package/dist_ts/utils.storecodecs.js +4 -4
- package/dist_ts/utils.storerules.d.ts +3 -3
- package/dist_ts/utils.storerules.js +1 -14
- package/dist_ts_mcp/index.d.ts +3 -1
- package/dist_ts_mcp/index.js +17 -2
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +18 -6
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +27 -16
- package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +33 -3
- package/dist_ts_stores_nosqldb/classes.recordstore.js +78 -18
- package/dist_ts_stores_nosqldb/index.d.ts +2 -2
- package/dist_ts_stores_nosqldb/index.js +2 -2
- package/package.json +4 -4
- package/readme.md +8 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.codesandbox.ts +132 -276
- package/ts/classes.codesandbox.worker.ts +345 -0
- package/ts/classes.flexharness.callbacks.ts +207 -55
- package/ts/classes.flexharness.core.ts +22 -16
- package/ts/classes.flexharness.results.ts +3 -3
- package/ts/classes.flexharness.reversions.ts +43 -32
- package/ts/classes.flexharness.runs.ts +6 -2
- package/ts/classes.flexharness.tooldispatch.ts +23 -7
- package/ts/classes.stores.ts +21 -12
- package/ts/errors.ts +14 -0
- package/ts/index.ts +7 -1
- package/ts/interfaces.flexharness.ts +15 -0
- package/ts/interfaces.ts +32 -9
- package/ts/plugins.ts +9 -12
- package/ts/readme.md +8 -2
- package/ts/utils.harnessoptions.ts +22 -0
- package/ts/utils.json.ts +112 -35
- package/ts/utils.storecodecs.ts +3 -3
- package/ts/utils.storerules.ts +3 -24
- package/ts_mcp/index.ts +17 -1
- package/ts_mcp/readme.md +3 -1
- package/ts_stores_nosqldb/classes.nosqldbstores.ts +39 -15
- package/ts_stores_nosqldb/classes.recordstore.ts +105 -13
- package/ts_stores_nosqldb/index.ts +2 -0
- package/ts_stores_nosqldb/readme.md +10 -2
package/ts/readme.md
CHANGED
|
@@ -293,6 +293,10 @@ const harness = new FlexHarness<IProjectScope>({
|
|
|
293
293
|
maxPageBytes: 1024 * 1024,
|
|
294
294
|
stagedImportIdleTimeoutMs: 5 * 60 * 1000,
|
|
295
295
|
},
|
|
296
|
+
resultLimits: {
|
|
297
|
+
maxStoredBytes: 8 * 1024 * 1024,
|
|
298
|
+
maxResultsPerSession: 32,
|
|
299
|
+
},
|
|
296
300
|
subagents: [
|
|
297
301
|
{
|
|
298
302
|
name: 'researcher',
|
|
@@ -1002,7 +1006,7 @@ FlexHarness wraps every provided tool `execute` method before AgentSession recei
|
|
|
1002
1006
|
|
|
1003
1007
|
`toolOutputLimits` in the complete setup above bounds traversal depth and encoded bytes. The normalizer enforces its byte allowance incrementally: oversized strings are replaced before entering output, and arrays/objects stop reading entries once only truncation metadata fits.
|
|
1004
1008
|
|
|
1005
|
-
Streaming callbacks use run-local synchronous state rather than one persistence promise per source delta. Text and reasoning accumulate only in the run-local terminal projection while each source delta remains an immediate exact public event. Every distinct async-iterable tool output appears immediately as a bounded cumulative `part.updated` snapshot while the tool remains `running`, including the final yielded value before completion. Only the authoritative `part.completed` output enters the terminal projection, and failed or interrupted tools discard their transient output. `callbackLimits` bounds callback events, accumulated output bytes, and part count; overflow aborts internally with `FlexHarnessCallbackOverflowError` and the turn is recorded as failed. Reservation and terminal finalization are the normal persistence checkpoints, with permission state changes as explicit additional checkpoints.
|
|
1009
|
+
Streaming callbacks use run-local synchronous state rather than one persistence promise per source delta. Text and reasoning accumulate only in the run-local terminal projection while each source delta remains an immediate exact public event. Every distinct async-iterable tool output appears immediately as a bounded cumulative `part.updated` snapshot while the tool remains `running`, including the final yielded value before completion. Only the authoritative `part.completed` output enters the terminal projection, and failed or interrupted tools discard their transient output. `callbackLimits` bounds callback events, accumulated output bytes, and part count; overflow aborts internally with `FlexHarnessCallbackOverflowError` and the turn is recorded as failed. The calls of a `run_code` program never overflow it: they stop their program at its `callbacks` limit instead. Reservation and terminal finalization are the normal persistence checkpoints, with permission state changes as explicit additional checkpoints.
|
|
1006
1010
|
|
|
1007
1011
|
Model resolver, tool provider, delegated run admission provider, AgentSession, tool execution, tool callback, tool cleanup, and run-persistence failures cross an untrusted error boundary. By default they become a fixed immutable `FlexHarnessExternalError` before completion rejection, persistence, events, or detached-cleanup reporting. Raw external messages and aggregate members are not retained. A failed `onToolCallFinish` callback stores and accounts for only the bounded projected message; it does not otherwise reject completion, although exceeding the configured callback limits still fails the run. Scope resolution and the initial store load happen before a run exists and remain outside this boundary.
|
|
1008
1012
|
|
|
@@ -1096,7 +1100,7 @@ const unsubscribe = harness.subscribe((event) => {
|
|
|
1096
1100
|
unsubscribe();
|
|
1097
1101
|
```
|
|
1098
1102
|
|
|
1099
|
-
Events are discriminated, deeply immutable values with one global sequence within each `FlexHarness` instance. Every `IFlexEventBase` carries the exact source `sessionGenerationId` and `sessionGenerationSequence`; `session.deleted` retains the deleted generation after live metadata is removed. Listener exceptions are isolated from runs and other listeners. Every accepted queue entry emits `prompt.queued` and exactly one `prompt.finished`. Every accepted steer emits `prompt.steer.accepted`, and `prompt.steer.applied`, after the `message.*` events of its user message, once the run took it in; `run.finished` lists it in `unappliedSteerIds` unless it is in the model context of later turns. Durable promotion additionally emits `prompt.started`, and actual model preparation emits `prompt.running`; cancellation or failure can omit either intermediate event. Existing durable run/message terminal events precede `prompt.finished`. Every callback-backed streamed text part emits exactly one `part.completed` event before the corresponding `run.finished` event. Every `part.started`, `part.delta`, `part.updated`, and `part.completed` event carries zero-based `messageIndex` and `partIndex` coordinates from the session's authoritative message and part sequences.
|
|
1103
|
+
Events are discriminated, deeply immutable values with one global sequence within each `FlexHarness` instance. Every `IFlexEventBase` carries the exact source `sessionGenerationId` and `sessionGenerationSequence`; `session.deleted` retains the deleted generation after live metadata is removed. Listener exceptions are isolated from runs and other listeners. Every accepted queue entry emits `prompt.queued` and exactly one `prompt.finished`. Every accepted steer emits `prompt.steer.accepted`, and `prompt.steer.applied`, after the `message.*` events of its user message, once the run took it in; `run.finished` lists it in `unappliedSteerIds` unless it is in the model context of later turns. Durable promotion additionally emits `prompt.started`, and actual model preparation emits `prompt.running`; cancellation or failure can omit either intermediate event. Existing durable run/message terminal events precede `prompt.finished`. Every callback-backed streamed text part emits exactly one `part.completed` event before the corresponding `run.finished` event: once the model step that streamed it is committed, so its text is final and saved, or with the run's terminal projection when that step did not commit. Text a later model step streams starts a new part. Every `part.started`, `part.delta`, `part.updated`, and `part.completed` event carries zero-based `messageIndex` and `partIndex` coordinates from the session's authoritative message and part sequences.
|
|
1100
1104
|
|
|
1101
1105
|
`getSessionView(scopeId, sessionId)` reads a session's metadata, visible messages and pending permission requests in one step, together with the instance's event `sequence` they include. The messages include what a running turn has streamed so far, so a consumer that applies every event of the instance with a greater `sequence` follows the session exactly from the view on; events up to `sequence` are already in it. The read waits while a queued write or a change that emits events for the session is in progress; a session that keeps changing through 64 consecutive waits is refused with `FlexHarnessSessionBusyError`, and the read can be repeated. Session metadata is current: status changes such as `waiting_permission` emit no event. A permission request its run leaves unanswered leaves `pendingPermissions` without an event of its own; the run's `run.finished` follows.
|
|
1102
1106
|
|
|
@@ -1121,6 +1125,8 @@ Current FlexHarness persistence is separated by trust and lifecycle domain throu
|
|
|
1121
1125
|
|
|
1122
1126
|
`InMemoryFlexHarnessStores` implements all six required domains with revision-based compare-and-swap behavior for tests and ephemeral processes. It is the default when `stores` is omitted. Custom `IFlexHarnessStores` implementations must provide `projectManagement` even when project-management tools are disabled, because deletion cleanup always writes the generation fence.
|
|
1123
1127
|
|
|
1128
|
+
A custom store validates what it loads and saves with the validators the built-in stores apply. Each throws `FlexHarnessStoreFormatError` for a malformed or wrong-schema snapshot: `assertFlexScopeSnapshot()`, `assertFlexProjectionSnapshot(value, sessionId)`, which also requires every message and staged terminal to belong to `sessionId`, `assertFlexPermissionSnapshot()`, `assertFlexProjectManagementRecord()` and `assertFlexToolJobSnapshot()`. Agent event snapshots and archives have `validateAgentEventSnapshotV2()` and `validateAgentEventArchiveV2()` in `@modelprofile.com/flexharness-agent`.
|
|
1129
|
+
|
|
1124
1130
|
Custom Agent event and job providers may implement `releaseSession(storageKey, sessionId)` to release session-bound wrappers, handles, or caches without deleting durable data. FlexHarness calls these hooks only after the corresponding AgentSession or execution context has released runtime ownership. A failed release remains owned for a later retirement or disposal retry. `deleteSession()` and `deleteSessionGenerationCohort()` are the separate destructive operations for durable session data.
|
|
1125
1131
|
|
|
1126
1132
|
`JsonFileFlexHarnessStores` stores the domains in separate `scopes`, `projections`, `permissions`, `projectManagement`, `events`, `archives`, and `jobs` directories. Storage and session identifiers are SHA-256 hashed for filenames. Passing the store bundle supplies lifecycle persistence but does not enable any built-in tool. It provides:
|
|
@@ -2,6 +2,7 @@ import { FlexHarnessValidationError } from './errors.js';
|
|
|
2
2
|
import {
|
|
3
3
|
FLEX_CODE_MODE_DEFAULT_LIMITS,
|
|
4
4
|
FLEX_CODE_MODE_MAXIMUM_LIMITS,
|
|
5
|
+
FLEX_RESULT_LIMITS,
|
|
5
6
|
FLEX_REVERSION_DEFAULT_LIMITS,
|
|
6
7
|
FLEX_TOOL_CATALOG_DEFAULT_OUTPUT_TYPE_DEPTH,
|
|
7
8
|
FLEX_TOOL_CATALOG_LIMITS,
|
|
@@ -12,7 +13,9 @@ import type {
|
|
|
12
13
|
IFlexCallbackLimits,
|
|
13
14
|
IFlexHarnessOptions,
|
|
14
15
|
IFlexHarnessStores,
|
|
16
|
+
IFlexJsonLimits,
|
|
15
17
|
IFlexPromptQueueLimits,
|
|
18
|
+
IFlexResultLimits,
|
|
16
19
|
IFlexReversionLimits,
|
|
17
20
|
IFlexSlashCommandHandlerRegistration,
|
|
18
21
|
IFlexSlashCommandTemplateRegistration,
|
|
@@ -452,6 +455,25 @@ export function resolveReversionLimits(
|
|
|
452
455
|
return resolved;
|
|
453
456
|
}
|
|
454
457
|
|
|
458
|
+
/** The result limits, resolved against the output budget: a stored result never holds less. */
|
|
459
|
+
export function resolveResultLimits(
|
|
460
|
+
limits: IFlexResultLimits = {},
|
|
461
|
+
toolOutputLimits: Required<IFlexJsonLimits>,
|
|
462
|
+
): Required<IFlexResultLimits> {
|
|
463
|
+
const maxStoredBytes = limits.maxStoredBytes
|
|
464
|
+
?? Math.max(FLEX_RESULT_LIMITS.maxStoredBytes, toolOutputLimits.maxBytes);
|
|
465
|
+
if (!Number.isSafeInteger(maxStoredBytes) || maxStoredBytes < toolOutputLimits.maxBytes) {
|
|
466
|
+
throw new FlexHarnessValidationError(
|
|
467
|
+
`resultLimits.maxStoredBytes must be an integer of at least toolOutputLimits.maxBytes (${toolOutputLimits.maxBytes}).`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const maxResultsPerSession = limits.maxResultsPerSession ?? FLEX_RESULT_LIMITS.maxResultsPerSession;
|
|
471
|
+
if (!Number.isSafeInteger(maxResultsPerSession) || maxResultsPerSession < 1) {
|
|
472
|
+
throw new FlexHarnessValidationError('resultLimits.maxResultsPerSession must be a positive integer.');
|
|
473
|
+
}
|
|
474
|
+
return { maxStoredBytes, maxResultsPerSession };
|
|
475
|
+
}
|
|
476
|
+
|
|
455
477
|
export function normalizeAgentSessionPolicy<TScope>(
|
|
456
478
|
policy: IFlexAgentSessionPolicy<TScope> = {},
|
|
457
479
|
): IFlexAgentSessionPolicy<TScope> {
|
package/ts/utils.json.ts
CHANGED
|
@@ -24,7 +24,8 @@ import { isValidSessionConfigurationRef } from './utils.validation.js';
|
|
|
24
24
|
|
|
25
25
|
const DEFAULT_MAX_DEPTH = 12;
|
|
26
26
|
const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
27
|
-
|
|
27
|
+
/** The least `maxBytes`: room for the marker that stands for a truncated value. */
|
|
28
|
+
export const MIN_MAX_BYTES = 128;
|
|
28
29
|
|
|
29
30
|
function isPlainObject(value: object): boolean {
|
|
30
31
|
const prototype = Object.getPrototypeOf(value);
|
|
@@ -321,62 +322,108 @@ export function normalizeJsonValue(value: unknown, limits: IFlexJsonLimits = {})
|
|
|
321
322
|
return makeDescription('truncated', { reason: 'bytes' });
|
|
322
323
|
}
|
|
323
324
|
|
|
324
|
-
|
|
325
|
+
const ARRAY_INDEX_PATTERN = /^(0|[1-9]\d*)$/;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* One walk over a value as JSON sees it. The path to the current value is kept as segments and only
|
|
329
|
+
* formatted for an error, and `ancestors` holds the objects on that path to detect cycles.
|
|
330
|
+
*/
|
|
331
|
+
interface IJsonWalk {
|
|
332
|
+
readonly root: string;
|
|
333
|
+
readonly segments: Array<string | number>;
|
|
334
|
+
readonly ancestors: object[];
|
|
335
|
+
readonly copy: boolean;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function failJsonWalk(walk: IJsonWalk, message: string): never {
|
|
339
|
+
let path = walk.root;
|
|
340
|
+
for (const segment of walk.segments) {
|
|
341
|
+
path += typeof segment === 'number' ? `[${segment}]` : `.${segment}`;
|
|
342
|
+
}
|
|
343
|
+
throw new FlexHarnessStoreFormatError(`${path} ${message}`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Rejects anything a JSON round trip would not preserve. With `walk.copy` it also returns the copy
|
|
348
|
+
* `JSON.parse(JSON.stringify(value))` would return, built in the same pass.
|
|
349
|
+
*/
|
|
350
|
+
function walkJsonValue(walk: IJsonWalk, value: unknown): unknown {
|
|
325
351
|
if (value === null || typeof value === 'string' || typeof value === 'boolean') {
|
|
326
|
-
return;
|
|
352
|
+
return value;
|
|
327
353
|
}
|
|
328
354
|
if (typeof value === 'number') {
|
|
329
|
-
if (!Number.isFinite(value))
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
return;
|
|
355
|
+
if (!Number.isFinite(value)) failJsonWalk(walk, 'contains a non-finite number.');
|
|
356
|
+
// JSON writes -0 as 0.
|
|
357
|
+
return value === 0 ? 0 : value;
|
|
333
358
|
}
|
|
334
359
|
if (typeof value !== 'object') {
|
|
335
|
-
|
|
360
|
+
failJsonWalk(walk, `contains ${typeof value}, which is not JSON-safe.`);
|
|
336
361
|
}
|
|
337
|
-
if (
|
|
338
|
-
|
|
362
|
+
if (walk.ancestors.includes(value)) {
|
|
363
|
+
failJsonWalk(walk, 'contains a circular reference.');
|
|
339
364
|
}
|
|
340
365
|
if (!Array.isArray(value) && !isPlainObject(value)) {
|
|
341
|
-
|
|
366
|
+
failJsonWalk(walk, 'contains a non-plain object.');
|
|
342
367
|
}
|
|
343
368
|
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
344
|
-
|
|
369
|
+
failJsonWalk(walk, 'contains a symbol-keyed property.');
|
|
345
370
|
}
|
|
346
|
-
|
|
371
|
+
walk.ancestors.push(value);
|
|
372
|
+
let copy: unknown;
|
|
347
373
|
if (Array.isArray(value)) {
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
for (let index = 0; index < value.length; index++) {
|
|
361
|
-
if (!(index in value)) {
|
|
362
|
-
throw new FlexHarnessStoreFormatError(`${path}[${index}] is a sparse array entry.`);
|
|
374
|
+
const length = value.length;
|
|
375
|
+
// Own names list array indices in ascending order, then `length`, then any other keys, so a
|
|
376
|
+
// dense array without extra properties has exactly its indices followed by `length`.
|
|
377
|
+
const names = Object.getOwnPropertyNames(value);
|
|
378
|
+
if (names.length !== length + 1 || names[length] !== 'length') {
|
|
379
|
+
const extraKey = Object.keys(value).find(
|
|
380
|
+
(key) => !ARRAY_INDEX_PATTERN.test(key) || Number(key) >= length,
|
|
381
|
+
);
|
|
382
|
+
if (extraKey) {
|
|
383
|
+
walk.segments.push(extraKey);
|
|
384
|
+
failJsonWalk(walk, 'is not a JSON array index.');
|
|
363
385
|
}
|
|
364
|
-
|
|
386
|
+
const customHiddenKey = names.find((key) => key !== 'length' && !ARRAY_INDEX_PATTERN.test(key));
|
|
387
|
+
if (customHiddenKey) {
|
|
388
|
+
walk.segments.push(customHiddenKey);
|
|
389
|
+
failJsonWalk(walk, 'is not JSON-visible.');
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const items: unknown[] | undefined = walk.copy ? [] : undefined;
|
|
393
|
+
for (let index = 0; index < length; index++) {
|
|
394
|
+
walk.segments.push(index);
|
|
395
|
+
if (!(index in value)) failJsonWalk(walk, 'is a sparse array entry.');
|
|
396
|
+
const item = walkJsonValue(walk, value[index]);
|
|
397
|
+
items?.push(item);
|
|
398
|
+
walk.segments.pop();
|
|
365
399
|
}
|
|
400
|
+
copy = items;
|
|
366
401
|
} else {
|
|
402
|
+
const fields: Record<string, unknown> | undefined = walk.copy ? {} : undefined;
|
|
367
403
|
for (const key of Object.getOwnPropertyNames(value)) {
|
|
368
404
|
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
|
405
|
+
walk.segments.push(key);
|
|
369
406
|
if (!descriptor.enumerable || !('value' in descriptor)) {
|
|
370
|
-
|
|
407
|
+
failJsonWalk(walk, 'is not a plain JSON property.');
|
|
408
|
+
}
|
|
409
|
+
const field = walkJsonValue(walk, descriptor.value);
|
|
410
|
+
if (fields && key in fields) {
|
|
411
|
+
// JSON.parse defines every key as an own property, so a key that names an inherited property,
|
|
412
|
+
// such as `__proto__`, neither sets the prototype nor reaches an inherited setter.
|
|
413
|
+
Object.defineProperty(fields, key, { value: field, enumerable: true, writable: true, configurable: true });
|
|
414
|
+
} else if (fields) {
|
|
415
|
+
fields[key] = field;
|
|
371
416
|
}
|
|
372
|
-
|
|
417
|
+
walk.segments.pop();
|
|
373
418
|
}
|
|
419
|
+
copy = fields;
|
|
374
420
|
}
|
|
375
|
-
|
|
421
|
+
walk.ancestors.pop();
|
|
422
|
+
return copy;
|
|
376
423
|
}
|
|
377
424
|
|
|
378
425
|
export function assertJsonSerializable(value: unknown, path = '$'): void {
|
|
379
|
-
|
|
426
|
+
walkJsonValue({ root: path, segments: [], ancestors: [], copy: false }, value);
|
|
380
427
|
}
|
|
381
428
|
|
|
382
429
|
function requireRecord(value: unknown, path: string): Record<string, unknown> {
|
|
@@ -1764,13 +1811,43 @@ export function assertFlexProjectionSnapshotV3(
|
|
|
1764
1811
|
assertFlexProjectionSnapshotV2(compatibleV2);
|
|
1765
1812
|
}
|
|
1766
1813
|
|
|
1814
|
+
/**
|
|
1815
|
+
* Validates a projection snapshot of any stored schema version. With `sessionId`, every message
|
|
1816
|
+
* and staged terminal must also belong to that session, as a store requires of what it loads or
|
|
1817
|
+
* saves under that session.
|
|
1818
|
+
*/
|
|
1767
1819
|
export function assertFlexProjectionSnapshot(
|
|
1768
1820
|
value: unknown,
|
|
1821
|
+
sessionId?: string,
|
|
1769
1822
|
): asserts value is IFlexProjectionSnapshotV1 | IFlexProjectionSnapshotV2 | IFlexProjectionSnapshotV3 {
|
|
1770
1823
|
const record = requireRecord(value, '$snapshot');
|
|
1771
1824
|
if (record.schemaVersion === 1) assertFlexProjectionSnapshotV1(value);
|
|
1772
1825
|
else if (record.schemaVersion === 2) assertFlexProjectionSnapshotV2(value);
|
|
1773
1826
|
else assertFlexProjectionSnapshotV3(value);
|
|
1827
|
+
if (sessionId !== undefined) assertProjectionSession(value, sessionId);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
function assertProjectionSession(
|
|
1831
|
+
snapshot: IFlexProjectionSnapshotV1 | IFlexProjectionSnapshotV2 | IFlexProjectionSnapshotV3,
|
|
1832
|
+
sessionId: string,
|
|
1833
|
+
): void {
|
|
1834
|
+
for (const message of snapshot.messages) {
|
|
1835
|
+
if (message.sessionId !== sessionId) {
|
|
1836
|
+
throw new FlexHarnessStoreFormatError(
|
|
1837
|
+
`Projection message "${message.messageId}" does not belong to session "${sessionId}".`,
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
for (const terminal of snapshot.stagedTerminals) {
|
|
1842
|
+
if (
|
|
1843
|
+
terminal.userMessage.sessionId !== sessionId
|
|
1844
|
+
|| terminal.assistantMessage.sessionId !== sessionId
|
|
1845
|
+
) {
|
|
1846
|
+
throw new FlexHarnessStoreFormatError(
|
|
1847
|
+
`Staged terminal "${terminal.runId}" does not belong to session "${sessionId}".`,
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1774
1851
|
}
|
|
1775
1852
|
|
|
1776
1853
|
export function assertFlexPermissionSnapshot(
|
|
@@ -1812,9 +1889,9 @@ export function canonicalJson(value: unknown): string | undefined {
|
|
|
1812
1889
|
return result === notJson ? undefined : JSON.stringify(result);
|
|
1813
1890
|
}
|
|
1814
1891
|
|
|
1892
|
+
/** Copies a JSON-safe value as a JSON round trip would, and rejects any other value. */
|
|
1815
1893
|
export function cloneSerializable<T>(value: T): T {
|
|
1816
|
-
|
|
1817
|
-
return JSON.parse(JSON.stringify(value)) as T;
|
|
1894
|
+
return walkJsonValue({ root: '$', segments: [], ancestors: [], copy: true }, value) as T;
|
|
1818
1895
|
}
|
|
1819
1896
|
|
|
1820
1897
|
export function deepFreeze<T>(value: T): T {
|
package/ts/utils.storecodecs.ts
CHANGED
|
@@ -139,7 +139,7 @@ export function validateAgentEventArchive(
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
export function
|
|
142
|
+
export function assertFlexToolJobSnapshot(value: unknown): asserts value is plugins.IToolJobSnapshot {
|
|
143
143
|
assertJsonSerializable(value, '$snapshot');
|
|
144
144
|
assertRecord(value, '$snapshot');
|
|
145
145
|
assertExactKeys(value, ['schemaVersion', 'revision', 'updatedAt', 'jobs'], '$snapshot');
|
|
@@ -229,7 +229,6 @@ export function validateToolJobSnapshot(value: unknown): plugins.IToolJobSnapsho
|
|
|
229
229
|
if (job[key] !== undefined) assertNonNegativeInteger(job[key], `${path}.${key}`);
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
|
-
return value as unknown as plugins.IToolJobSnapshot;
|
|
233
232
|
}
|
|
234
233
|
|
|
235
234
|
export function createToolJobSnapshot(
|
|
@@ -242,5 +241,6 @@ export function createToolJobSnapshot(
|
|
|
242
241
|
updatedAt: Date.now(),
|
|
243
242
|
jobs: normalizeJsonObjectUndefined([...jobs], '$jobs'),
|
|
244
243
|
};
|
|
245
|
-
|
|
244
|
+
assertFlexToolJobSnapshot(snapshot);
|
|
245
|
+
return snapshot;
|
|
246
246
|
}
|
package/ts/utils.storerules.ts
CHANGED
|
@@ -27,9 +27,8 @@ type TSnapshot =
|
|
|
27
27
|
| TFlexProjectionSnapshot
|
|
28
28
|
| IFlexPermissionSnapshot
|
|
29
29
|
| TFlexProjectManagementRecord;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
) => asserts value is TSnapshotValue;
|
|
30
|
+
/** Throws when `value` is not a valid snapshot of the domain being saved. */
|
|
31
|
+
type TSnapshotValidator = (value: unknown) => void;
|
|
33
32
|
|
|
34
33
|
export function validateIdentifier(value: string, name: string): void {
|
|
35
34
|
if (typeof value !== 'string' || !value.trim()) {
|
|
@@ -60,7 +59,7 @@ export function validateProjectManagementWriteContext(
|
|
|
60
59
|
export function validateSnapshotSave<TSnapshotValue extends TSnapshot>(
|
|
61
60
|
snapshot: TSnapshotValue,
|
|
62
61
|
expectedRevision: number,
|
|
63
|
-
validator: TSnapshotValidator
|
|
62
|
+
validator: TSnapshotValidator,
|
|
64
63
|
): TSnapshotValue {
|
|
65
64
|
validator(snapshot);
|
|
66
65
|
validateExpectedRevision(expectedRevision);
|
|
@@ -78,26 +77,6 @@ export function providerKey(storageKey: string, sessionId: string): string {
|
|
|
78
77
|
return JSON.stringify([storageKey, sessionId]);
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
export function assertProjectionSession(snapshot: TFlexProjectionSnapshot, sessionId: string): void {
|
|
82
|
-
for (const message of snapshot.messages) {
|
|
83
|
-
if (message.sessionId !== sessionId) {
|
|
84
|
-
throw new FlexHarnessStoreFormatError(
|
|
85
|
-
`Projection message "${message.messageId}" does not belong to session "${sessionId}".`,
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
for (const terminal of snapshot.stagedTerminals) {
|
|
90
|
-
if (
|
|
91
|
-
terminal.userMessage.sessionId !== sessionId
|
|
92
|
-
|| terminal.assistantMessage.sessionId !== sessionId
|
|
93
|
-
) {
|
|
94
|
-
throw new FlexHarnessStoreFormatError(
|
|
95
|
-
`Staged terminal "${terminal.runId}" does not belong to session "${sessionId}".`,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
80
|
function sameProjectManagementGeneration(
|
|
102
81
|
left: TFlexProjectManagementRecord,
|
|
103
82
|
right: TFlexProjectManagementRecord,
|
package/ts_mcp/index.ts
CHANGED
|
@@ -255,7 +255,9 @@ export interface ICreateMcpToolCatalogResult<TScope = unknown> {
|
|
|
255
255
|
* The MCP tools of `servers` as a tool catalog, which the model searches, describes and calls through
|
|
256
256
|
* `execute` and `run_code`. A method's path is the name `createMcpTools` gives the tool; a name that
|
|
257
257
|
* is no identifier (with `-`, or a reserved word) is declared as `FlexToolCatalog.identifierOf()`
|
|
258
|
-
* makes it.
|
|
258
|
+
* makes it. A prefixed method has the tool's own name as an alias, so a search that names the tool
|
|
259
|
+
* without its server declares it, as one naming the method does. Create one catalog per set of tool
|
|
260
|
+
* definitions and return the same object on every run.
|
|
259
261
|
*/
|
|
260
262
|
export const createMcpToolCatalog = <TScope = unknown>(
|
|
261
263
|
options: ICreateMcpToolCatalogOptions<TScope>,
|
|
@@ -286,10 +288,12 @@ export const createMcpToolCatalog = <TScope = unknown>(
|
|
|
286
288
|
const description = text === summary ? undefined : truncateUtf8(text, plugins.FLEX_TOOL_CATALOG_LIMITS.maxDescriptionBytes);
|
|
287
289
|
const readOnly = (server.trustReadOnlyHints === true && mcpTool.annotations?.readOnlyHint === true)
|
|
288
290
|
|| options.isReadOnly?.(toolContext) === true;
|
|
291
|
+
const alias = shouldPrefixToolNames ? unprefixedAliasOf(mcpTool.name) : undefined;
|
|
289
292
|
methods.push({
|
|
290
293
|
method: path,
|
|
291
294
|
summary,
|
|
292
295
|
...(description ? { description } : {}),
|
|
296
|
+
...(alias ? { aliases: [alias] } : {}),
|
|
293
297
|
inputSchema: mcpTool.inputSchema,
|
|
294
298
|
...(readOnly ? { readOnly: true } : {}),
|
|
295
299
|
run: (params, call) => callMcpTool({
|
|
@@ -532,6 +536,18 @@ const callMcpTool = async (call: IMcpToolCall): Promise<unknown> => {
|
|
|
532
536
|
/** A catalog method path: the exposed name where it is an identifier, else the identifier made of it. */
|
|
533
537
|
const catalogPathOf = (exposedToolName: string): string => plugins.FlexToolCatalog.identifierOf(exposedToolName);
|
|
534
538
|
|
|
539
|
+
/**
|
|
540
|
+
* A prefixed method's search alias: the tool's own name, as its exposed name holds it after the
|
|
541
|
+
* server prefix, so that a search naming the tool without its server names the method. A name the
|
|
542
|
+
* catalog cannot take as an alias, longer than an alias may be or without a letter or digit, has none.
|
|
543
|
+
*/
|
|
544
|
+
const unprefixedAliasOf = (toolName: string): string | undefined => {
|
|
545
|
+
const alias = sanitizeMcpToolName(toolName);
|
|
546
|
+
return alias.length <= plugins.FLEX_TOOL_CATALOG_LIMITS.maxAliasCharacters && /[a-zA-Z0-9]/.test(alias)
|
|
547
|
+
? alias
|
|
548
|
+
: undefined;
|
|
549
|
+
};
|
|
550
|
+
|
|
535
551
|
/** The first line of a description, as one line of at most the catalog's summary length. */
|
|
536
552
|
const summaryOf = (text: string): string => {
|
|
537
553
|
const line = text.split(/\r?\n/).find((candidate) => candidate.trim())!.replace(/[\s\u0000-\u001f\u007f]+/g, ' ').trim();
|
package/ts_mcp/readme.md
CHANGED
|
@@ -18,7 +18,9 @@ by default; `servers` carries each server's `trustReadOnlyHints`.
|
|
|
18
18
|
run, each call on the client `resolveClient` names for its run, method paths equal to
|
|
19
19
|
`createMcpTools()`' tool names, `authorizeToolCall` deciding catalog calls as it decides
|
|
20
20
|
tool calls, and read-only methods where a server whose entry sets `trustReadOnlyHints: true`
|
|
21
|
-
annotates its tool `readOnlyHint: true` or where `isReadOnly` declares them.
|
|
21
|
+
annotates its tool `readOnlyHint: true` or where `isReadOnly` declares them. A prefixed method
|
|
22
|
+
has the tool's own name as a search alias, so `search_tools` finds and declares it for a query
|
|
23
|
+
that names the tool without its server. The root readme's
|
|
22
24
|
Tool Catalog section shows a tool provider that connects clients per run.
|
|
23
25
|
|
|
24
26
|
See the [toolbox usage and migration guide](https://code.foss.global/modelprofile.com/flexharness#readme)
|
|
@@ -31,15 +31,14 @@ import {
|
|
|
31
31
|
assertFlexProjectManagementTombstone,
|
|
32
32
|
} from '../ts/utils.projectmanagement.js';
|
|
33
33
|
import {
|
|
34
|
+
assertFlexToolJobSnapshot,
|
|
34
35
|
createAgentEventSnapshot,
|
|
35
36
|
createToolJobSnapshot,
|
|
36
37
|
normalizeJsonObjectUndefined,
|
|
37
38
|
validateAgentEventArchive,
|
|
38
39
|
validateAgentEventSnapshot,
|
|
39
|
-
validateToolJobSnapshot,
|
|
40
40
|
} from '../ts/utils.storecodecs.js';
|
|
41
41
|
import {
|
|
42
|
-
assertProjectionSession,
|
|
43
42
|
assertProjectManagementSaveAdmitted,
|
|
44
43
|
assertSameArchiveEvents,
|
|
45
44
|
isProjectManagementTombstoneAdmitted,
|
|
@@ -73,6 +72,11 @@ function validatePermission(value: unknown): IFlexPermissionSnapshot {
|
|
|
73
72
|
return value;
|
|
74
73
|
}
|
|
75
74
|
|
|
75
|
+
function validateToolJobs(value: unknown): plugins.IToolJobSnapshot {
|
|
76
|
+
assertFlexToolJobSnapshot(value);
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
76
80
|
function validateProjectManagement(value: unknown): TFlexProjectManagementRecord {
|
|
77
81
|
assertFlexProjectManagementRecord(value);
|
|
78
82
|
return value;
|
|
@@ -108,8 +112,7 @@ class NoSqlProjectionStore implements IFlexProjectionStore {
|
|
|
108
112
|
|
|
109
113
|
private validator(sessionId: string): (value: unknown) => TFlexProjectionSnapshot {
|
|
110
114
|
return (value) => {
|
|
111
|
-
assertFlexProjectionSnapshot(value);
|
|
112
|
-
assertProjectionSession(value, sessionId);
|
|
115
|
+
assertFlexProjectionSnapshot(value, sessionId);
|
|
113
116
|
return value;
|
|
114
117
|
};
|
|
115
118
|
}
|
|
@@ -129,8 +132,11 @@ class NoSqlProjectionStore implements IFlexProjectionStore {
|
|
|
129
132
|
expectedRevision: number,
|
|
130
133
|
): Promise<void> {
|
|
131
134
|
const key = providerKey(storageKey, sessionId);
|
|
132
|
-
const validated = validateSnapshotSave(
|
|
133
|
-
|
|
135
|
+
const validated = validateSnapshotSave(
|
|
136
|
+
snapshot,
|
|
137
|
+
expectedRevision,
|
|
138
|
+
(value) => assertFlexProjectionSnapshot(value, sessionId),
|
|
139
|
+
);
|
|
134
140
|
const conflict = (actualRevision: number) =>
|
|
135
141
|
new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
136
142
|
await this.records.mutate({ domain: 'projection', storageKey, sessionId }, {
|
|
@@ -365,7 +371,7 @@ class NoSqlToolJobStore implements plugins.IToolJobStore {
|
|
|
365
371
|
abortSignal?.throwIfAborted();
|
|
366
372
|
const snapshot = await this.records.read(
|
|
367
373
|
{ domain: 'jobs', storageKey: this.storageKey, sessionId: this.sessionId },
|
|
368
|
-
|
|
374
|
+
validateToolJobs,
|
|
369
375
|
);
|
|
370
376
|
abortSignal?.throwIfAborted();
|
|
371
377
|
return snapshot;
|
|
@@ -384,7 +390,7 @@ class NoSqlToolJobStore implements plugins.IToolJobStore {
|
|
|
384
390
|
await this.records.mutate(
|
|
385
391
|
{ domain: 'jobs', storageKey: this.storageKey, sessionId: this.sessionId },
|
|
386
392
|
{
|
|
387
|
-
validate:
|
|
393
|
+
validate: validateToolJobs,
|
|
388
394
|
decide: (current) => {
|
|
389
395
|
abortSignal?.throwIfAborted();
|
|
390
396
|
return revisionWrite(current, snapshot, expectedRevision, conflict);
|
|
@@ -432,6 +438,15 @@ export interface INoSqlFlexHarnessStoresOptions {
|
|
|
432
438
|
operationTimeoutMs?: number;
|
|
433
439
|
}
|
|
434
440
|
|
|
441
|
+
export interface INoSqlFlexHarnessPurgeOptions {
|
|
442
|
+
/**
|
|
443
|
+
* Fences the namespace in the purge's transaction: every later record creation in it is
|
|
444
|
+
* refused with `FlexHarnessStorePurgedError`, from any process and for good, so a harness that
|
|
445
|
+
* still holds the scope cannot write it back. A purge without the fence never lifts one.
|
|
446
|
+
*/
|
|
447
|
+
fence?: boolean;
|
|
448
|
+
}
|
|
449
|
+
|
|
435
450
|
/**
|
|
436
451
|
* One record per result and an index record of the session's handles, oldest first, changed with
|
|
437
452
|
* compare-and-swap. A result whose handle the index lost to a failed write stays until its session
|
|
@@ -495,7 +510,8 @@ function validateResult(value: unknown): TJsonValue {
|
|
|
495
510
|
/**
|
|
496
511
|
* Every FlexHarness persistence domain on `@lossless.org/client/nosqldb`, with compare-and-swap
|
|
497
512
|
* that holds across processes. Records live in `flexharness_records`; payloads longer than one
|
|
498
|
-
* chunk live in `flexharness_chunks
|
|
513
|
+
* chunk live in `flexharness_chunks`; `flexharness_namespaces` holds each storage namespace's
|
|
514
|
+
* fence.
|
|
499
515
|
*/
|
|
500
516
|
export class NoSqlFlexHarnessStores implements IFlexHarnessStores {
|
|
501
517
|
public readonly scopes: IFlexScopeStore;
|
|
@@ -522,7 +538,7 @@ export class NoSqlFlexHarnessStores implements IFlexHarnessStores {
|
|
|
522
538
|
}
|
|
523
539
|
|
|
524
540
|
/**
|
|
525
|
-
* Creates or verifies the
|
|
541
|
+
* Creates or verifies the three collections and their indexes. Every store operation awaits the
|
|
526
542
|
* same preparation, so calling it at startup only moves that work, and its failure, forward.
|
|
527
543
|
*/
|
|
528
544
|
public prepare(): Promise<void> {
|
|
@@ -533,12 +549,20 @@ export class NoSqlFlexHarnessStores implements IFlexHarnessStores {
|
|
|
533
549
|
* Physically removes every record and chunk of one storage namespace in every domain: the
|
|
534
550
|
* scope snapshot, each session's projection, permissions, Agent events and archives, jobs and
|
|
535
551
|
* results, and the project-management records and tombstones. This is the host's durable
|
|
536
|
-
* deletion of a scope.
|
|
537
|
-
*
|
|
538
|
-
* removes the
|
|
552
|
+
* deletion of a scope. Stop new admission for every alias of the scope first. Without `fence`,
|
|
553
|
+
* also await `retireScope()` on every harness that owns it: purging under a live harness
|
|
554
|
+
* removes the revisions its writes would conflict with, so it can write records back. With
|
|
555
|
+
* `fence`, the namespace refuses every later record creation, from any process and for good,
|
|
556
|
+
* so such a harness gets `FlexHarnessStorePurgedError` instead; the storage key is never usable
|
|
557
|
+
* again.
|
|
539
558
|
*/
|
|
540
|
-
public purgeNamespace(storageKey: string): Promise<void> {
|
|
559
|
+
public purgeNamespace(storageKey: string, options: INoSqlFlexHarnessPurgeOptions = {}): Promise<void> {
|
|
541
560
|
validateIdentifier(storageKey, 'storageKey');
|
|
542
|
-
|
|
561
|
+
if (options.fence !== undefined && typeof options.fence !== 'boolean') {
|
|
562
|
+
throw new FlexHarnessValidationError('fence must be a boolean.');
|
|
563
|
+
}
|
|
564
|
+
return options.fence
|
|
565
|
+
? this.records.fenceNamespace(storageKey)
|
|
566
|
+
: this.records.purgeNamespace(storageKey);
|
|
543
567
|
}
|
|
544
568
|
}
|