@modelprofile.com/flexharness 10.15.1 → 10.17.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.
Files changed (54) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.codesandbox.js +6 -2
  3. package/dist_ts/classes.flexharness.d.ts +36 -0
  4. package/dist_ts/classes.flexharness.js +360 -56
  5. package/dist_ts/classes.stores.d.ts +3 -1
  6. package/dist_ts/classes.stores.js +92 -2
  7. package/dist_ts/classes.toolcatalog.d.ts +18 -1
  8. package/dist_ts/classes.toolcatalog.js +179 -18
  9. package/dist_ts/interfaces.d.ts +67 -0
  10. package/dist_ts/interfaces.js +12 -2
  11. package/dist_ts/plugins.d.ts +3 -3
  12. package/dist_ts/plugins.js +3 -3
  13. package/dist_ts/utils.json.d.ts +5 -0
  14. package/dist_ts/utils.json.js +35 -1
  15. package/dist_ts/utils.results.d.ts +21 -0
  16. package/dist_ts/utils.results.js +158 -0
  17. package/dist_ts/utils.storerules.d.ts +5 -0
  18. package/dist_ts/utils.storerules.js +10 -1
  19. package/dist_ts/utils.tooloutput.d.ts +5 -2
  20. package/dist_ts/utils.tooloutput.js +10 -3
  21. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +2 -1
  22. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +63 -5
  23. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +6 -3
  24. package/dist_ts_stores_nosqldb/classes.recordstore.js +8 -2
  25. package/dist_ts_typedrequest/catalog.d.ts +28 -0
  26. package/dist_ts_typedrequest/catalog.js +79 -0
  27. package/dist_ts_typedrequest/describe.d.ts +10 -0
  28. package/dist_ts_typedrequest/describe.js +189 -0
  29. package/dist_ts_typedrequest/index.d.ts +3 -0
  30. package/dist_ts_typedrequest/index.js +4 -0
  31. package/dist_ts_typedrequest/interfaces.d.ts +107 -0
  32. package/dist_ts_typedrequest/interfaces.js +2 -0
  33. package/dist_ts_typedrequest/plugins.d.ts +7 -0
  34. package/dist_ts_typedrequest/plugins.js +11 -0
  35. package/package.json +14 -4
  36. package/ts/00_commitinfo_data.ts +1 -1
  37. package/ts/classes.codesandbox.ts +5 -1
  38. package/ts/classes.flexharness.ts +419 -55
  39. package/ts/classes.stores.ts +109 -0
  40. package/ts/classes.toolcatalog.ts +177 -14
  41. package/ts/interfaces.ts +69 -1
  42. package/ts/plugins.ts +3 -0
  43. package/ts/utils.json.ts +30 -0
  44. package/ts/utils.results.ts +164 -0
  45. package/ts/utils.storerules.ts +15 -0
  46. package/ts/utils.tooloutput.ts +9 -2
  47. package/ts_stores_nosqldb/classes.nosqldbstores.ts +68 -2
  48. package/ts_stores_nosqldb/classes.recordstore.ts +14 -4
  49. package/ts_typedrequest/catalog.ts +91 -0
  50. package/ts_typedrequest/describe.ts +211 -0
  51. package/ts_typedrequest/index.ts +3 -0
  52. package/ts_typedrequest/interfaces.ts +124 -0
  53. package/ts_typedrequest/plugins.ts +15 -0
  54. package/ts_typedrequest/readme.md +66 -0
package/ts/utils.json.ts CHANGED
@@ -786,6 +786,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
786
786
  'error',
787
787
  'childSessionId',
788
788
  'parentToolCallId',
789
+ 'deduplicatedFrom',
789
790
  'model',
790
791
  'startedAt',
791
792
  'completedAt',
@@ -803,6 +804,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
803
804
  requireOptionalString(part.error, `${partPath}.error`);
804
805
  requireOptionalString(part.childSessionId, `${partPath}.childSessionId`);
805
806
  requireOptionalString(part.parentToolCallId, `${partPath}.parentToolCallId`);
807
+ requireOptionalString(part.deduplicatedFrom, `${partPath}.deduplicatedFrom`);
806
808
  if (part.model !== undefined) validateModel(part.model, `${partPath}.model`);
807
809
  requireOptionalString(part.startedAt, `${partPath}.startedAt`);
808
810
  requireOptionalString(part.completedAt, `${partPath}.completedAt`);
@@ -1763,6 +1765,34 @@ export function assertFlexPermissionSnapshot(
1763
1765
  validatePermissionKeys(snapshot.rememberedPermissionKeys, '$snapshot.rememberedPermissionKeys');
1764
1766
  }
1765
1767
 
1768
+ /**
1769
+ * The JSON text of a plain JSON value with every object's keys sorted, so equal values read the
1770
+ * same; undefined for anything else, such as a Date or a Map, whose JSON could equal another's.
1771
+ */
1772
+ export function canonicalJson(value: unknown): string | undefined {
1773
+ const notJson = Symbol('not JSON');
1774
+ const canonical = (entry: unknown): unknown => {
1775
+ if (entry === null || typeof entry === 'string' || typeof entry === 'boolean') return entry;
1776
+ if (typeof entry === 'number') return Number.isFinite(entry) ? entry : notJson;
1777
+ if (Array.isArray(entry)) {
1778
+ const items = entry.map(canonical);
1779
+ return items.includes(notJson) ? notJson : items;
1780
+ }
1781
+ if (typeof entry !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(entry))) return notJson;
1782
+ const sorted: Record<string, unknown> = {};
1783
+ for (const key of Object.keys(entry).sort()) {
1784
+ const field = (entry as Record<string, unknown>)[key];
1785
+ if (field === undefined) continue;
1786
+ const canonicalField = canonical(field);
1787
+ if (canonicalField === notJson) return notJson;
1788
+ sorted[key] = canonicalField;
1789
+ }
1790
+ return sorted;
1791
+ };
1792
+ const result = canonical(value);
1793
+ return result === notJson ? undefined : JSON.stringify(result);
1794
+ }
1795
+
1766
1796
  export function cloneSerializable<T>(value: T): T {
1767
1797
  assertJsonSerializable(value);
1768
1798
  return JSON.parse(JSON.stringify(value)) as T;
@@ -0,0 +1,164 @@
1
+ import type { TJsonValue } from './interfaces.js';
2
+
3
+ /**
4
+ * Stored tool results: the summary the model gets in place of an output over its budget, and the
5
+ * pages `results.get` reads from it. A page holds whole array items, whole object entries or a
6
+ * slice of a string; an item or entry too large for a page stands as a marker naming its path,
7
+ * which `results.get` reads in turn.
8
+ */
9
+
10
+ const jsonBytes = (value: unknown): number => Buffer.byteLength(JSON.stringify(value) ?? 'null');
11
+
12
+ const isRecord = (value: TJsonValue): value is { [key: string]: TJsonValue } =>
13
+ typeof value === 'object' && value !== null && !Array.isArray(value);
14
+
15
+ /** The shape of a value in a few words, such as `array(120)` or `string(2048 chars)`. */
16
+ export function describeShape(value: TJsonValue): string {
17
+ if (Array.isArray(value)) return `array(${value.length})`;
18
+ if (isRecord(value)) return `object(${Object.keys(value).length} keys)`;
19
+ if (typeof value === 'string') return `string(${value.length} chars)`;
20
+ return value === null ? 'null' : typeof value;
21
+ }
22
+
23
+ /** What a stored result holds, within about `budgetBytes`: its shape and a preview of its start. */
24
+ export function summarizeResult(value: TJsonValue, budgetBytes: number): TJsonValue {
25
+ if (Array.isArray(value)) {
26
+ const preview: TJsonValue[] = [];
27
+ let bytes = 0;
28
+ for (const item of value) {
29
+ const itemBytes = jsonBytes(item) + 1;
30
+ if (bytes + itemBytes > budgetBytes) break;
31
+ preview.push(item);
32
+ bytes += itemBytes;
33
+ }
34
+ const first = value[0];
35
+ return {
36
+ type: 'array',
37
+ length: value.length,
38
+ ...(first !== undefined && isRecord(first) ? { itemKeys: Object.keys(first).slice(0, 40) } : {}),
39
+ preview,
40
+ };
41
+ }
42
+ if (isRecord(value)) {
43
+ const keys: { [key: string]: TJsonValue } = {};
44
+ let bytes = 0;
45
+ for (const [key, entry] of Object.entries(value)) {
46
+ const shape = describeShape(entry);
47
+ bytes += jsonBytes(key) + jsonBytes(shape) + 2;
48
+ if (bytes > budgetBytes) break;
49
+ keys[key] = shape;
50
+ }
51
+ return { type: 'object', size: Object.keys(value).length, keys };
52
+ }
53
+ if (typeof value === 'string') {
54
+ return { type: 'string', length: value.length, preview: sliceWithin(value, 0, budgetBytes).text };
55
+ }
56
+ return { type: value === null ? 'null' : typeof value, value };
57
+ }
58
+
59
+ /** The value at a dotted `path`, such as `items.3.lines`; undefined when it has none. */
60
+ export function selectPath(value: TJsonValue, path: string): TJsonValue | undefined {
61
+ let current: TJsonValue | undefined = value;
62
+ for (const segment of path === '' ? [] : path.split('.')) {
63
+ if (Array.isArray(current)) {
64
+ current = /^(?:0|[1-9]\d*)$/.test(segment) ? current[Number(segment)] : undefined;
65
+ } else if (current !== undefined && isRecord(current)) {
66
+ current = Object.prototype.hasOwnProperty.call(current, segment) ? current[segment] : undefined;
67
+ } else {
68
+ return undefined;
69
+ }
70
+ }
71
+ return current;
72
+ }
73
+
74
+ export interface IResultPage {
75
+ pages: number;
76
+ value: TJsonValue;
77
+ /** The array items (`items`), object entries (`entries`) or string characters (`chars`) the page holds, as [from, to). */
78
+ range?: { items: [number, number] } | { entries: [number, number] } | { chars: [number, number] };
79
+ }
80
+
81
+ /** Page `page` (1-based) of `value`, each page at most `pageBytes` of JSON; undefined past the last. */
82
+ export function resultPage(value: TJsonValue, path: string, page: number, pageBytes: number): IResultPage | undefined {
83
+ const at = (key: string) => path === '' ? key : `${path}.${key}`;
84
+ const oversize = (key: string, entry: TJsonValue): TJsonValue => ({
85
+ $flexType: 'oversize',
86
+ path: at(key),
87
+ bytes: jsonBytes(entry),
88
+ shape: describeShape(entry),
89
+ });
90
+ if (Array.isArray(value)) {
91
+ const bounds = packPages(value.map((item, index) => {
92
+ const bytes = jsonBytes(item) + 1;
93
+ return bytes <= pageBytes ? { value: item, bytes } : { value: oversize(String(index), item), bytes: 0 };
94
+ }), pageBytes);
95
+ const bound = bounds[page - 1];
96
+ if (!bound) return page === 1 && value.length === 0 ? { pages: 1, value: [], range: { items: [0, 0] } } : undefined;
97
+ return { pages: bounds.length, value: bound.values, range: { items: [bound.from, bound.to] } };
98
+ }
99
+ if (isRecord(value)) {
100
+ const entries = Object.entries(value);
101
+ const bounds = packPages(entries.map(([key, entry]) => {
102
+ const bytes = jsonBytes(key) + jsonBytes(entry) + 2;
103
+ return { value: bytes <= pageBytes ? entry : oversize(key, entry), bytes: bytes <= pageBytes ? bytes : 0 };
104
+ }), pageBytes);
105
+ const bound = bounds[page - 1];
106
+ if (!bound) return page === 1 && entries.length === 0 ? { pages: 1, value: {}, range: { entries: [0, 0] } } : undefined;
107
+ const pageValue: { [key: string]: TJsonValue } = {};
108
+ entries.slice(bound.from, bound.to).forEach(([key], index) => {
109
+ pageValue[key] = bound.values[index];
110
+ });
111
+ return { pages: bounds.length, value: pageValue, range: { entries: [bound.from, bound.to] } };
112
+ }
113
+ if (typeof value === 'string') {
114
+ const slices: Array<[number, number]> = [];
115
+ for (let from = 0; from < value.length || slices.length === 0;) {
116
+ const slice = sliceWithin(value, from, pageBytes);
117
+ slices.push([from, slice.end]);
118
+ if (slice.end === from) break;
119
+ from = slice.end;
120
+ }
121
+ const slice = slices[page - 1];
122
+ if (!slice) return undefined;
123
+ return { pages: slices.length, value: value.slice(slice[0], slice[1]), range: { chars: slice } };
124
+ }
125
+ return page === 1 ? { pages: 1, value } : undefined;
126
+ }
127
+
128
+ /** Packs sized values into consecutive pages of at most `pageBytes`; a zero-sized value always fits. */
129
+ function packPages(
130
+ sized: Array<{ value: TJsonValue; bytes: number }>,
131
+ pageBytes: number,
132
+ ): Array<{ from: number; to: number; values: TJsonValue[] }> {
133
+ const pages: Array<{ from: number; to: number; values: TJsonValue[] }> = [];
134
+ let current: { from: number; to: number; values: TJsonValue[] } | undefined;
135
+ let bytes = 0;
136
+ sized.forEach((entry, index) => {
137
+ // An oversize marker is small; give it room as if it were 256 bytes.
138
+ const entryBytes = entry.bytes === 0 ? 256 : entry.bytes;
139
+ if (!current || bytes + entryBytes > pageBytes) {
140
+ current = { from: index, to: index, values: [] };
141
+ pages.push(current);
142
+ bytes = 0;
143
+ }
144
+ current.values.push(entry.value);
145
+ current.to = index + 1;
146
+ bytes += entryBytes;
147
+ });
148
+ return pages;
149
+ }
150
+
151
+ /** The longest slice of `text` from `from` whose JSON encoding fits `maxBytes`, never splitting a surrogate pair. */
152
+ function sliceWithin(text: string, from: number, maxBytes: number): { text: string; end: number } {
153
+ let low = from;
154
+ let high = text.length;
155
+ while (low < high) {
156
+ const middle = Math.ceil((low + high) / 2);
157
+ if (jsonBytes(text.slice(from, middle)) <= maxBytes) low = middle;
158
+ else high = middle - 1;
159
+ }
160
+ let end = low;
161
+ const last = text.charCodeAt(end - 1);
162
+ if (end > from && end < text.length && last >= 0xd800 && last <= 0xdbff) end--;
163
+ return { text: text.slice(from, end), end };
164
+ }
@@ -175,3 +175,18 @@ export function assertSameArchiveEvents(
175
175
  throw new Error(`Archive "${next.archiveId}" already exists with different events.`);
176
176
  }
177
177
  }
178
+
179
+ /** The index of a session's stored results: their handles, oldest first. */
180
+ export interface IFlexResultIndex {
181
+ handles: string[];
182
+ }
183
+
184
+ export function validateResultIndex(value: unknown): IFlexResultIndex {
185
+ assertRecord(value, '$resultIndex');
186
+ assertExactKeys(value, ['handles'], '$resultIndex');
187
+ const handles = value.handles;
188
+ if (!Array.isArray(handles) || !handles.every((handle) => typeof handle === 'string')) {
189
+ throw new FlexHarnessStoreFormatError('$resultIndex.handles must list handles.');
190
+ }
191
+ return { handles: [...handles] };
192
+ }
@@ -1,11 +1,11 @@
1
- import type { IFlexHarnessOptions, TFlexAgentToolSet } from './interfaces.js';
1
+ import type { IFlexHarnessOptions, TFlexAgentToolSet, TJsonValue } from './interfaces.js';
2
2
  import { normalizeJsonValue } from './utils.json.js';
3
3
 
4
4
  interface IExecutableToolRecord extends Record<string, unknown> {
5
5
  execute?: (input: unknown, options: unknown) => unknown;
6
6
  }
7
7
 
8
- function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
8
+ export function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
9
9
  return Boolean(
10
10
  value
11
11
  && (typeof value === 'object' || typeof value === 'function')
@@ -38,6 +38,8 @@ export function wrapToolSet(
38
38
  tools: TFlexAgentToolSet,
39
39
  limits: Required<NonNullable<IFlexHarnessOptions<unknown>['toolOutputLimits']>>,
40
40
  projectError: (error: unknown) => Error,
41
+ /** Bounds a final output that is not streamed, in place of normalizing it to `limits`. */
42
+ budget?: (output: unknown) => Promise<TJsonValue>,
41
43
  ): TFlexAgentToolSet {
42
44
  const wrapped: Record<string, unknown> = {};
43
45
  for (const [name, tool] of Object.entries(tools)) {
@@ -65,6 +67,11 @@ export function wrapToolSet(
65
67
  if (isAsyncIterable(value)) {
66
68
  return normalizeAsyncIterable(value, limits, projectError);
67
69
  }
70
+ if (budget) {
71
+ return budget(value).catch((error: unknown) => {
72
+ throw projectError(error);
73
+ });
74
+ }
68
75
  return normalizeJsonValue(value, limits);
69
76
  } catch (error) {
70
77
  throw projectError(error);
@@ -11,16 +11,19 @@ import type {
11
11
  IFlexProjectManagementWriteContext,
12
12
  IFlexProjectionSnapshotV3,
13
13
  IFlexProjectionStore,
14
+ IFlexResultStore,
14
15
  IFlexScopeSnapshot,
15
16
  IFlexScopeStore,
16
17
  IFlexToolJobStoreProvider,
17
18
  TFlexProjectionSnapshot,
18
19
  TFlexProjectManagementRecord,
20
+ TJsonValue,
19
21
  } from '../ts/interfaces.js';
20
22
  import {
21
23
  assertFlexPermissionSnapshot,
22
24
  assertFlexProjectionSnapshot,
23
25
  assertFlexScopeSnapshot,
26
+ assertJsonSerializable,
24
27
  } from '../ts/utils.json.js';
25
28
  import {
26
29
  assertFlexProjectManagementRecord,
@@ -44,6 +47,7 @@ import {
44
47
  validateExpectedRevision,
45
48
  validateIdentifier,
46
49
  validateProjectManagementWriteContext,
50
+ validateResultIndex,
47
51
  validateSnapshotSave,
48
52
  } from '../ts/utils.storerules.js';
49
53
  import { FlexRecordStore, type IFlexRecordWrite } from './classes.recordstore.js';
@@ -297,7 +301,7 @@ class NoSqlAgentEventStore implements plugins.IAgentEventStoreV2 {
297
301
  domain: 'agentEventArchive' as const,
298
302
  storageKey: this.storageKey,
299
303
  sessionId: this.sessionId,
300
- archiveId: archive.archiveId,
304
+ entryId: archive.archiveId,
301
305
  };
302
306
  await this.records.mutate(address, {
303
307
  validate: (value) => validateAgentEventArchive(value, this.sessionId, archive.archiveId),
@@ -318,7 +322,7 @@ class NoSqlAgentEventStore implements plugins.IAgentEventStoreV2 {
318
322
  this.assertSession(sessionId);
319
323
  validateIdentifier(archiveId, 'archiveId');
320
324
  return this.records.read(
321
- { domain: 'agentEventArchive', storageKey: this.storageKey, sessionId, archiveId },
325
+ { domain: 'agentEventArchive', storageKey: this.storageKey, sessionId, entryId: archiveId },
322
326
  (value) => validateAgentEventArchive(value, this.sessionId, archiveId),
323
327
  );
324
328
  }
@@ -428,6 +432,66 @@ export interface INoSqlFlexHarnessStoresOptions {
428
432
  operationTimeoutMs?: number;
429
433
  }
430
434
 
435
+ /**
436
+ * One record per result and an index record of the session's handles, oldest first, changed with
437
+ * compare-and-swap. A result whose handle the index lost to a failed write stays until its session
438
+ * is deleted.
439
+ */
440
+ class NoSqlResultStore implements IFlexResultStore {
441
+ constructor(private readonly records: FlexRecordStore) {}
442
+
443
+ public async save(storageKey: string, sessionId: string, handle: string, value: TJsonValue): Promise<void> {
444
+ validateIdentifier(handle, 'handle');
445
+ assertJsonSerializable(value);
446
+ const conflict = () => new FlexHarnessStoreConflictError(`${providerKey(storageKey, sessionId)}:${handle}`, 0, 1);
447
+ await this.records.mutate({ domain: 'result', storageKey, sessionId, entryId: handle }, {
448
+ validate: validateResult,
449
+ decide: (current) => {
450
+ if (current !== undefined) throw conflict();
451
+ return { revision: 1, value };
452
+ },
453
+ contention: conflict,
454
+ });
455
+ await this.changeIndex(storageKey, sessionId, (handles) => [...handles, handle]);
456
+ }
457
+
458
+ public load(storageKey: string, sessionId: string, handle: string): Promise<TJsonValue | undefined> {
459
+ validateIdentifier(handle, 'handle');
460
+ return this.records.read({ domain: 'result', storageKey, sessionId, entryId: handle }, validateResult);
461
+ }
462
+
463
+ public async list(storageKey: string, sessionId: string): Promise<string[]> {
464
+ const index = await this.records.read({ domain: 'result', storageKey, sessionId }, validateResultIndex);
465
+ return index?.handles ?? [];
466
+ }
467
+
468
+ public async delete(storageKey: string, sessionId: string, handle: string): Promise<void> {
469
+ validateIdentifier(handle, 'handle');
470
+ await this.changeIndex(storageKey, sessionId, (handles) => handles.filter((entry) => entry !== handle));
471
+ await this.records.delete({ domain: 'result', storageKey, sessionId, entryId: handle });
472
+ }
473
+
474
+ public deleteSession(storageKey: string, sessionId: string): Promise<void> {
475
+ return this.records.deleteOwner(storageKey, sessionId, ['result']);
476
+ }
477
+
478
+ /** The session's index record: the result records' handles, without an entry of its own. */
479
+ private changeIndex(storageKey: string, sessionId: string, change: (handles: string[]) => string[]): Promise<void> {
480
+ const contention = () => new Error(`The result index of session "${sessionId}" is contended by concurrent writers.`);
481
+ return this.records.mutate({ domain: 'result', storageKey, sessionId }, {
482
+ validate: validateResultIndex,
483
+ // The write token fences concurrent writers; the index keeps no revision of its own.
484
+ decide: (current) => ({ revision: 1, value: { handles: change(current?.handles ?? []) } }),
485
+ contention,
486
+ });
487
+ }
488
+ }
489
+
490
+ function validateResult(value: unknown): TJsonValue {
491
+ assertJsonSerializable(value);
492
+ return value as TJsonValue;
493
+ }
494
+
431
495
  /**
432
496
  * Every FlexHarness persistence domain on `@lossless.org/client/nosqldb`, with compare-and-swap
433
497
  * that holds across processes. Records live in `flexharness_records`; payloads longer than one
@@ -440,6 +504,7 @@ export class NoSqlFlexHarnessStores implements IFlexHarnessStores {
440
504
  public readonly projectManagement: IFlexProjectManagementStore;
441
505
  public readonly agentEvents: IFlexAgentEventStoreProvider;
442
506
  public readonly jobs: IFlexToolJobStoreProvider;
507
+ public readonly results: IFlexResultStore;
443
508
  private readonly records: FlexRecordStore;
444
509
 
445
510
  constructor(options: INoSqlFlexHarnessStoresOptions) {
@@ -453,6 +518,7 @@ export class NoSqlFlexHarnessStores implements IFlexHarnessStores {
453
518
  this.projectManagement = new NoSqlProjectManagementStore(this.records);
454
519
  this.agentEvents = new NoSqlAgentEventStoreProvider(this.records);
455
520
  this.jobs = new NoSqlToolJobStoreProvider(this.records);
521
+ this.results = new NoSqlResultStore(this.records);
456
522
  }
457
523
 
458
524
  /**
@@ -12,14 +12,16 @@ export type TFlexRecordDomain =
12
12
  | 'projectManagement'
13
13
  | 'agentEvents'
14
14
  | 'agentEventArchive'
15
- | 'jobs';
15
+ | 'jobs'
16
+ | 'result';
16
17
 
17
- /** Names one record: a domain snapshot of a storage key, a session or an archive. */
18
+ /** Names one record: a domain snapshot of a storage key or a session, or one entry of a session. */
18
19
  export interface IFlexRecordAddress {
19
20
  domain: TFlexRecordDomain;
20
21
  storageKey: string;
21
22
  sessionId?: string;
22
- archiveId?: string;
23
+ /** The archive or result the record holds. */
24
+ entryId?: string;
23
25
  }
24
26
 
25
27
  /** The next stored value a mutation decided on. */
@@ -256,7 +258,7 @@ export class FlexRecordStore {
256
258
  address.domain,
257
259
  address.storageKey,
258
260
  address.sessionId ?? null,
259
- address.archiveId ?? null,
261
+ address.entryId ?? null,
260
262
  ]));
261
263
  return {
262
264
  recordId,
@@ -295,6 +297,13 @@ export class FlexRecordStore {
295
297
  throw mutation.contention(actualRevision);
296
298
  }
297
299
 
300
+ /** Deletes one record and its chunks. */
301
+ public async delete(address: IFlexRecordAddress): Promise<void> {
302
+ await this.prepare();
303
+ const resolved = this.resolve(address);
304
+ await this.deleteInTransaction(resolved.path, { recordId: resolved.recordId, domain: resolved.domain });
305
+ }
306
+
298
307
  /** Deletes every record and chunk one session owns in the given domains. */
299
308
  public async deleteOwner(
300
309
  storageKey: string,
@@ -322,6 +331,7 @@ export class FlexRecordStore {
322
331
  private async deleteInTransaction(
323
332
  path: string,
324
333
  selector:
334
+ | { recordId: string; domain: TFlexRecordDomain }
325
335
  | { ownerKey: string; domain: { $in: TFlexRecordDomain[] } }
326
336
  | { namespaceKey: string; domain: TFlexRecordDomain },
327
337
  ): Promise<void> {
@@ -0,0 +1,91 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IFlexCatalogMethod, IFlexCatalogRunContext, IFlexToolCatalog } from '@modelprofile.com/flexharness';
3
+ import type {
4
+ IFlexTypedRequestCatalogOptions,
5
+ IFlexTypedRequestMethod,
6
+ IFlexTypedRequestSurface,
7
+ } from './interfaces.js';
8
+
9
+ /**
10
+ * A typed request the router refused: the `text` and `data` of its error, as a client of the API
11
+ * would receive them. A host's `externalErrorProjector` decides what of it the model sees.
12
+ */
13
+ export class FlexTypedRequestError extends Error {
14
+ constructor(
15
+ /** The typed method: `getVoucher`. */
16
+ public readonly method: string,
17
+ /** The error's text, such as a `TypedResponseError`'s message. */
18
+ public readonly text: string,
19
+ /** The error's data. */
20
+ public readonly data?: unknown,
21
+ ) {
22
+ super(`${method} was refused: ${text}`);
23
+ this.name = 'FlexTypedRequestError';
24
+ }
25
+ }
26
+
27
+ /**
28
+ * The tool catalog of a typedrequest surface: every included method becomes a catalog method of
29
+ * its path, declared as its contract writes it and validated against its schema, and runs through
30
+ * `router.routeAndAddResponse()` in process, its request the model's parameters with the omitted
31
+ * properties `request` fills. A refusal rejects with `FlexTypedRequestError`.
32
+ */
33
+ export function catalogFromTypedRequests<TScope = unknown>(
34
+ surface: IFlexTypedRequestSurface,
35
+ options: IFlexTypedRequestCatalogOptions<TScope>,
36
+ ): IFlexToolCatalog<TScope> {
37
+ if (typeof options?.router?.routeAndAddResponse !== 'function') {
38
+ throw new plugins.FlexHarnessValidationError('catalogFromTypedRequests needs a router with routeAndAddResponse().');
39
+ }
40
+ const aliases = options.aliases ?? {};
41
+ const known = new Set(surface.methods.map((method) => method.method));
42
+ const unknown = Object.keys(aliases).filter((method) => !known.has(method));
43
+ if (unknown.length > 0) {
44
+ throw new plugins.FlexHarnessValidationError(`The surface has no method ${unknown.join(', ')} to give aliases.`);
45
+ }
46
+ if (surface.omit.length > 0 && !options.request) {
47
+ throw new plugins.FlexHarnessValidationError(
48
+ `The surface leaves ${surface.omit.join(', ')} out of every request; the options' request() must fill them.`,
49
+ );
50
+ }
51
+ const include = options.include ?? ((method: IFlexTypedRequestMethod) => !method.deprecated);
52
+ const { router } = options;
53
+ const call = async (
54
+ method: IFlexTypedRequestMethod,
55
+ params: Record<string, unknown>,
56
+ context: IFlexCatalogRunContext<TScope>,
57
+ ): Promise<unknown> => {
58
+ const filled = options.request ? await options.request(method, context) : {};
59
+ const localData = options.localData ? await options.localData(method, context) : undefined;
60
+ const answer = await router.routeAndAddResponse(
61
+ {
62
+ method: method.method,
63
+ request: { ...params, ...filled },
64
+ response: {},
65
+ correlation: { id: plugins.crypto.randomUUID(), phase: 'request' },
66
+ },
67
+ { ...(localData ? { trustedLocalData: localData } : {}), trustedAbortSignal: context.abortSignal },
68
+ );
69
+ if (!answer) throw new FlexTypedRequestError(method.method, 'The router answered nothing.');
70
+ if (answer.error) throw new FlexTypedRequestError(method.method, answer.error.text, answer.error.data);
71
+ return answer.response;
72
+ };
73
+ return {
74
+ methods: surface.methods.filter((method) => include(method)).map((method): IFlexCatalogMethod<TScope> => ({
75
+ method: method.path,
76
+ summary: method.summary,
77
+ ...(method.description ? { description: method.description } : {}),
78
+ ...(aliases[method.method] ? { aliases: aliases[method.method] } : {}),
79
+ inputSchema: method.inputSchema,
80
+ outputSchema: method.outputSchema,
81
+ declaration: method.declaration,
82
+ run: (params: Record<string, unknown>, context) => call(method, params, context),
83
+ ...(options.onCall
84
+ ? { onCall: (params: Record<string, unknown>, context: IFlexCatalogRunContext<TScope>) => options.onCall!(method, params, context) }
85
+ : {}),
86
+ })),
87
+ types: surface.types,
88
+ ...(options.vocabulary ? { vocabulary: options.vocabulary } : {}),
89
+ ...(options.onProposals ? { onProposals: options.onProposals } : {}),
90
+ };
91
+ }