@modelprofile.com/flexharness 11.0.1 → 11.1.1

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.
Files changed (69) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.codesandbox.d.ts +12 -4
  3. package/dist_ts/classes.codesandbox.js +119 -287
  4. package/dist_ts/classes.codesandbox.worker.d.ts +52 -0
  5. package/dist_ts/classes.codesandbox.worker.js +310 -0
  6. package/dist_ts/classes.flexharness.callbacks.d.ts +37 -1
  7. package/dist_ts/classes.flexharness.callbacks.js +162 -53
  8. package/dist_ts/classes.flexharness.core.d.ts +2 -1
  9. package/dist_ts/classes.flexharness.core.js +22 -18
  10. package/dist_ts/classes.flexharness.results.js +4 -4
  11. package/dist_ts/classes.flexharness.reversions.d.ts +7 -0
  12. package/dist_ts/classes.flexharness.reversions.js +40 -39
  13. package/dist_ts/classes.flexharness.runs.js +7 -3
  14. package/dist_ts/classes.flexharness.tooldispatch.d.ts +2 -1
  15. package/dist_ts/classes.flexharness.tooldispatch.js +27 -9
  16. package/dist_ts/classes.stores.js +15 -13
  17. package/dist_ts/errors.d.ts +5 -0
  18. package/dist_ts/errors.js +10 -1
  19. package/dist_ts/index.d.ts +2 -1
  20. package/dist_ts/index.js +3 -2
  21. package/dist_ts/interfaces.d.ts +31 -9
  22. package/dist_ts/interfaces.flexharness.d.ts +14 -0
  23. package/dist_ts/interfaces.js +3 -3
  24. package/dist_ts/plugins.d.ts +6 -4
  25. package/dist_ts/plugins.js +10 -11
  26. package/dist_ts/utils.harnessoptions.d.ts +3 -1
  27. package/dist_ts/utils.harnessoptions.js +15 -2
  28. package/dist_ts/utils.json.d.ts +9 -1
  29. package/dist_ts/utils.json.js +92 -33
  30. package/dist_ts/utils.storecodecs.d.ts +1 -1
  31. package/dist_ts/utils.storecodecs.js +4 -4
  32. package/dist_ts/utils.storerules.d.ts +3 -3
  33. package/dist_ts/utils.storerules.js +1 -14
  34. package/dist_ts_mcp/index.d.ts +3 -1
  35. package/dist_ts_mcp/index.js +17 -2
  36. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +18 -6
  37. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +27 -16
  38. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +33 -3
  39. package/dist_ts_stores_nosqldb/classes.recordstore.js +78 -18
  40. package/dist_ts_stores_nosqldb/index.d.ts +2 -2
  41. package/dist_ts_stores_nosqldb/index.js +2 -2
  42. package/package.json +3 -3
  43. package/readme.md +8 -2
  44. package/ts/00_commitinfo_data.ts +1 -1
  45. package/ts/classes.codesandbox.ts +132 -276
  46. package/ts/classes.codesandbox.worker.ts +345 -0
  47. package/ts/classes.flexharness.callbacks.ts +207 -55
  48. package/ts/classes.flexharness.core.ts +22 -16
  49. package/ts/classes.flexharness.results.ts +3 -3
  50. package/ts/classes.flexharness.reversions.ts +43 -32
  51. package/ts/classes.flexharness.runs.ts +6 -2
  52. package/ts/classes.flexharness.tooldispatch.ts +23 -7
  53. package/ts/classes.stores.ts +21 -12
  54. package/ts/errors.ts +14 -0
  55. package/ts/index.ts +7 -1
  56. package/ts/interfaces.flexharness.ts +15 -0
  57. package/ts/interfaces.ts +32 -9
  58. package/ts/plugins.ts +9 -12
  59. package/ts/readme.md +8 -2
  60. package/ts/utils.harnessoptions.ts +22 -0
  61. package/ts/utils.json.ts +112 -35
  62. package/ts/utils.storecodecs.ts +3 -3
  63. package/ts/utils.storerules.ts +3 -24
  64. package/ts_mcp/index.ts +17 -1
  65. package/ts_mcp/readme.md +3 -1
  66. package/ts_stores_nosqldb/classes.nosqldbstores.ts +39 -15
  67. package/ts_stores_nosqldb/classes.recordstore.ts +105 -13
  68. package/ts_stores_nosqldb/index.ts +2 -0
  69. 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
- const MIN_MAX_BYTES = 128;
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
- function assertJsonSerializableAt(value: unknown, path: string, seen: WeakSet<object>): void {
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
- throw new FlexHarnessStoreFormatError(`${path} contains a non-finite number.`);
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
- throw new FlexHarnessStoreFormatError(`${path} contains ${typeof value}, which is not JSON-safe.`);
360
+ failJsonWalk(walk, `contains ${typeof value}, which is not JSON-safe.`);
336
361
  }
337
- if (seen.has(value)) {
338
- throw new FlexHarnessStoreFormatError(`${path} contains a circular reference.`);
362
+ if (walk.ancestors.includes(value)) {
363
+ failJsonWalk(walk, 'contains a circular reference.');
339
364
  }
340
365
  if (!Array.isArray(value) && !isPlainObject(value)) {
341
- throw new FlexHarnessStoreFormatError(`${path} contains a non-plain object.`);
366
+ failJsonWalk(walk, 'contains a non-plain object.');
342
367
  }
343
368
  if (Object.getOwnPropertySymbols(value).length > 0) {
344
- throw new FlexHarnessStoreFormatError(`${path} contains a symbol-keyed property.`);
369
+ failJsonWalk(walk, 'contains a symbol-keyed property.');
345
370
  }
346
- seen.add(value);
371
+ walk.ancestors.push(value);
372
+ let copy: unknown;
347
373
  if (Array.isArray(value)) {
348
- const extraKey = Object.keys(value).find(
349
- (key) => !/^(0|[1-9]\d*)$/.test(key) || Number(key) >= value.length,
350
- );
351
- if (extraKey) {
352
- throw new FlexHarnessStoreFormatError(`${path}.${extraKey} is not a JSON array index.`);
353
- }
354
- const customHiddenKey = Object.getOwnPropertyNames(value).find(
355
- (key) => key !== 'length' && !/^(0|[1-9]\d*)$/.test(key),
356
- );
357
- if (customHiddenKey) {
358
- throw new FlexHarnessStoreFormatError(`${path}.${customHiddenKey} is not JSON-visible.`);
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
- assertJsonSerializableAt(value[index], `${path}[${index}]`, seen);
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
- throw new FlexHarnessStoreFormatError(`${path}.${key} is not a plain JSON property.`);
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
- assertJsonSerializableAt(descriptor.value, `${path}.${key}`, seen);
417
+ walk.segments.pop();
373
418
  }
419
+ copy = fields;
374
420
  }
375
- seen.delete(value);
421
+ walk.ancestors.pop();
422
+ return copy;
376
423
  }
377
424
 
378
425
  export function assertJsonSerializable(value: unknown, path = '$'): void {
379
- assertJsonSerializableAt(value, path, new WeakSet<object>());
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
- assertJsonSerializable(value);
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 {
@@ -139,7 +139,7 @@ export function validateAgentEventArchive(
139
139
  }
140
140
  }
141
141
 
142
- export function validateToolJobSnapshot(value: unknown): plugins.IToolJobSnapshot {
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
- return validateToolJobSnapshot(snapshot);
244
+ assertFlexToolJobSnapshot(snapshot);
245
+ return snapshot;
246
246
  }
@@ -27,9 +27,8 @@ type TSnapshot =
27
27
  | TFlexProjectionSnapshot
28
28
  | IFlexPermissionSnapshot
29
29
  | TFlexProjectManagementRecord;
30
- type TSnapshotValidator<TSnapshotValue extends TSnapshot> = (
31
- value: unknown,
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<TSnapshotValue>,
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. Create one catalog per set of tool definitions and return the same object on every run.
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. The root readme's
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(snapshot, expectedRevision, assertFlexProjectionSnapshot);
133
- assertProjectionSession(validated, sessionId);
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
- validateToolJobSnapshot,
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: validateToolJobSnapshot,
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 two collections and their indexes. Every store operation awaits 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. Call it only after stopping new admission for every alias of the scope
537
- * and awaiting `retireScope()` on every harness that owns it; purging under a live harness
538
- * removes the fences that keep its writes safe.
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
- return this.records.purgeNamespace(storageKey);
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
  }