@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.codesandbox.js +6 -2
- package/dist_ts/classes.flexharness.d.ts +36 -0
- package/dist_ts/classes.flexharness.js +360 -56
- package/dist_ts/classes.stores.d.ts +3 -1
- package/dist_ts/classes.stores.js +92 -2
- package/dist_ts/classes.toolcatalog.d.ts +18 -1
- package/dist_ts/classes.toolcatalog.js +179 -18
- package/dist_ts/interfaces.d.ts +67 -0
- package/dist_ts/interfaces.js +12 -2
- package/dist_ts/plugins.d.ts +3 -3
- package/dist_ts/plugins.js +3 -3
- package/dist_ts/utils.json.d.ts +5 -0
- package/dist_ts/utils.json.js +35 -1
- package/dist_ts/utils.results.d.ts +21 -0
- package/dist_ts/utils.results.js +158 -0
- package/dist_ts/utils.storerules.d.ts +5 -0
- package/dist_ts/utils.storerules.js +10 -1
- package/dist_ts/utils.tooloutput.d.ts +5 -2
- package/dist_ts/utils.tooloutput.js +10 -3
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +2 -1
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +63 -5
- package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +6 -3
- package/dist_ts_stores_nosqldb/classes.recordstore.js +8 -2
- package/dist_ts_typedrequest/catalog.d.ts +28 -0
- package/dist_ts_typedrequest/catalog.js +79 -0
- package/dist_ts_typedrequest/describe.d.ts +10 -0
- package/dist_ts_typedrequest/describe.js +189 -0
- package/dist_ts_typedrequest/index.d.ts +3 -0
- package/dist_ts_typedrequest/index.js +4 -0
- package/dist_ts_typedrequest/interfaces.d.ts +107 -0
- package/dist_ts_typedrequest/interfaces.js +2 -0
- package/dist_ts_typedrequest/plugins.d.ts +7 -0
- package/dist_ts_typedrequest/plugins.js +11 -0
- package/package.json +14 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.codesandbox.ts +5 -1
- package/ts/classes.flexharness.ts +419 -55
- package/ts/classes.stores.ts +109 -0
- package/ts/classes.toolcatalog.ts +177 -14
- package/ts/interfaces.ts +69 -1
- package/ts/plugins.ts +3 -0
- package/ts/utils.json.ts +30 -0
- package/ts/utils.results.ts +164 -0
- package/ts/utils.storerules.ts +15 -0
- package/ts/utils.tooloutput.ts +9 -2
- package/ts_stores_nosqldb/classes.nosqldbstores.ts +68 -2
- package/ts_stores_nosqldb/classes.recordstore.ts +14 -4
- package/ts_typedrequest/catalog.ts +91 -0
- package/ts_typedrequest/describe.ts +211 -0
- package/ts_typedrequest/index.ts +3 -0
- package/ts_typedrequest/interfaces.ts +124 -0
- package/ts_typedrequest/plugins.ts +15 -0
- 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
|
+
}
|
package/ts/utils.storerules.ts
CHANGED
|
@@ -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
|
+
}
|
package/ts/utils.tooloutput.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|