@modelprofile.com/flexharness 10.15.1 → 10.16.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 +33 -0
- package/dist_ts/classes.flexharness.js +328 -45
- package/dist_ts/classes.stores.d.ts +3 -1
- package/dist_ts/classes.stores.js +59 -1
- package/dist_ts/classes.toolcatalog.d.ts +9 -1
- package/dist_ts/classes.toolcatalog.js +82 -11
- package/dist_ts/interfaces.d.ts +59 -0
- package/dist_ts/interfaces.js +9 -1
- 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.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 +37 -4
- package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +4 -3
- package/dist_ts_stores_nosqldb/classes.recordstore.js +2 -2
- package/package.json +3 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.codesandbox.ts +5 -1
- package/ts/classes.flexharness.ts +382 -44
- package/ts/classes.stores.ts +66 -0
- package/ts/classes.toolcatalog.ts +87 -7
- package/ts/interfaces.ts +60 -0
- package/ts/plugins.ts +3 -0
- package/ts/utils.json.ts +30 -0
- package/ts/utils.results.ts +164 -0
- package/ts/utils.tooloutput.ts +9 -2
- package/ts_stores_nosqldb/classes.nosqldbstores.ts +39 -2
- package/ts_stores_nosqldb/classes.recordstore.ts +6 -4
package/ts/classes.stores.ts
CHANGED
|
@@ -16,9 +16,11 @@ import type {
|
|
|
16
16
|
IFlexProjectManagementWriteContext,
|
|
17
17
|
IFlexProjectionSnapshotV3,
|
|
18
18
|
IFlexProjectionStore,
|
|
19
|
+
IFlexResultStore,
|
|
19
20
|
IFlexScopeSnapshot,
|
|
20
21
|
IFlexScopeStore,
|
|
21
22
|
IFlexToolJobStoreProvider,
|
|
23
|
+
TJsonValue,
|
|
22
24
|
TFlexProjectionSnapshot,
|
|
23
25
|
TFlexProjectManagementRecord,
|
|
24
26
|
} from './interfaces.js';
|
|
@@ -336,6 +338,28 @@ class InMemoryToolJobStoreProvider implements IFlexToolJobStoreProvider {
|
|
|
336
338
|
}
|
|
337
339
|
}
|
|
338
340
|
|
|
341
|
+
class InMemoryResultStore implements IFlexResultStore {
|
|
342
|
+
private readonly sessions = new Map<string, Map<string, TJsonValue>>();
|
|
343
|
+
|
|
344
|
+
public async save(storageKey: string, sessionId: string, handle: string, value: TJsonValue): Promise<void> {
|
|
345
|
+
validateIdentifier(handle, 'handle');
|
|
346
|
+
const key = providerKey(storageKey, sessionId);
|
|
347
|
+
const results = this.sessions.get(key) ?? new Map<string, TJsonValue>();
|
|
348
|
+
if (results.has(handle)) throw new FlexHarnessStoreConflictError(`${key}:${handle}`, 0, 1);
|
|
349
|
+
results.set(handle, cloneSerializable(value));
|
|
350
|
+
this.sessions.set(key, results);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
public async load(storageKey: string, sessionId: string, handle: string): Promise<TJsonValue | undefined> {
|
|
354
|
+
const value = this.sessions.get(providerKey(storageKey, sessionId))?.get(handle);
|
|
355
|
+
return value === undefined ? undefined : cloneSerializable(value);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
public async deleteSession(storageKey: string, sessionId: string): Promise<void> {
|
|
359
|
+
this.sessions.delete(providerKey(storageKey, sessionId));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
339
363
|
export class InMemoryFlexHarnessStores implements IFlexHarnessStores {
|
|
340
364
|
public readonly scopes: IFlexScopeStore = new InMemoryScopeStore();
|
|
341
365
|
public readonly projections: IFlexProjectionStore = new InMemoryProjectionStore();
|
|
@@ -343,6 +367,7 @@ export class InMemoryFlexHarnessStores implements IFlexHarnessStores {
|
|
|
343
367
|
public readonly agentEvents: IFlexAgentEventStoreProvider = new InMemoryAgentEventStoreProvider();
|
|
344
368
|
public readonly jobs: IFlexToolJobStoreProvider = new InMemoryToolJobStoreProvider();
|
|
345
369
|
public readonly projectManagement = new InMemoryFlexProjectManagementStore();
|
|
370
|
+
public readonly results: IFlexResultStore = new InMemoryResultStore();
|
|
346
371
|
}
|
|
347
372
|
|
|
348
373
|
const jsonDomains = [
|
|
@@ -353,6 +378,7 @@ const jsonDomains = [
|
|
|
353
378
|
'events',
|
|
354
379
|
'archives',
|
|
355
380
|
'jobs',
|
|
381
|
+
'results',
|
|
356
382
|
] as const;
|
|
357
383
|
type TJsonDomain = (typeof jsonDomains)[number];
|
|
358
384
|
|
|
@@ -433,6 +459,17 @@ class JsonFileRoot {
|
|
|
433
459
|
);
|
|
434
460
|
}
|
|
435
461
|
|
|
462
|
+
public resultPath(storageKey: string, sessionId: string, handle: string): string {
|
|
463
|
+
validateIdentifier(handle, 'handle');
|
|
464
|
+
return plugins.path.join(this.resultSessionPath(storageKey, sessionId), `${this.digest(handle)}.json`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
public resultSessionPath(storageKey: string, sessionId: string): string {
|
|
468
|
+
validateIdentifier(storageKey, 'storageKey');
|
|
469
|
+
validateIdentifier(sessionId, 'sessionId');
|
|
470
|
+
return plugins.path.join(this.directory, 'results', this.digest(storageKey), this.digest(sessionId));
|
|
471
|
+
}
|
|
472
|
+
|
|
436
473
|
public eventQueueKey(storageKey: string, sessionId: string): string {
|
|
437
474
|
return `event:${this.sessionPath('events', storageKey, sessionId)}`;
|
|
438
475
|
}
|
|
@@ -851,6 +888,33 @@ class JsonPermissionStore implements IFlexPermissionStore {
|
|
|
851
888
|
}
|
|
852
889
|
}
|
|
853
890
|
|
|
891
|
+
class JsonResultStore implements IFlexResultStore {
|
|
892
|
+
constructor(private readonly root: JsonFileRoot) {}
|
|
893
|
+
|
|
894
|
+
public async save(storageKey: string, sessionId: string, handle: string, value: TJsonValue): Promise<void> {
|
|
895
|
+
const filePath = this.root.resultPath(storageKey, sessionId, handle);
|
|
896
|
+
await JsonFileRoot.inQueue(filePath, async () => {
|
|
897
|
+
if (await this.root.read(filePath, (stored) => stored) !== undefined) {
|
|
898
|
+
throw new FlexHarnessStoreConflictError(`${providerKey(storageKey, sessionId)}:${handle}`, 0, 1);
|
|
899
|
+
}
|
|
900
|
+
await this.root.write(filePath, value);
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
public async load(storageKey: string, sessionId: string, handle: string): Promise<TJsonValue | undefined> {
|
|
905
|
+
const filePath = this.root.resultPath(storageKey, sessionId, handle);
|
|
906
|
+
return JsonFileRoot.inQueue(filePath, () => this.root.read(filePath, (value) => {
|
|
907
|
+
assertJsonSerializable(value);
|
|
908
|
+
return value as TJsonValue;
|
|
909
|
+
}));
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
public async deleteSession(storageKey: string, sessionId: string): Promise<void> {
|
|
913
|
+
const directory = this.root.resultSessionPath(storageKey, sessionId);
|
|
914
|
+
await JsonFileRoot.inQueue(`results:${directory}`, () => this.root.deleteDirectory(directory));
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
854
918
|
class JsonProjectManagementStore implements IFlexProjectManagementStore {
|
|
855
919
|
constructor(private readonly root: JsonFileRoot) {}
|
|
856
920
|
|
|
@@ -1186,6 +1250,7 @@ export class JsonFileFlexHarnessStores implements IFlexHarnessStores {
|
|
|
1186
1250
|
public readonly agentEvents: IFlexAgentEventStoreProvider;
|
|
1187
1251
|
public readonly jobs: IFlexToolJobStoreProvider;
|
|
1188
1252
|
public readonly projectManagement: IFlexProjectManagementStore;
|
|
1253
|
+
public readonly results: IFlexResultStore;
|
|
1189
1254
|
private readonly root: JsonFileRoot;
|
|
1190
1255
|
|
|
1191
1256
|
constructor(options: IJsonFileFlexHarnessStoresOptions) {
|
|
@@ -1199,6 +1264,7 @@ export class JsonFileFlexHarnessStores implements IFlexHarnessStores {
|
|
|
1199
1264
|
this.projectManagement = new JsonProjectManagementStore(this.root);
|
|
1200
1265
|
this.agentEvents = new JsonAgentEventStoreProvider(this.root);
|
|
1201
1266
|
this.jobs = new JsonToolJobStoreProvider(this.root);
|
|
1267
|
+
this.results = new JsonResultStore(this.root);
|
|
1202
1268
|
}
|
|
1203
1269
|
|
|
1204
1270
|
public dispose(): Promise<void> {
|
|
@@ -2,6 +2,7 @@ import * as plugins from './plugins.js';
|
|
|
2
2
|
import { FlexHarnessValidationError } from './errors.js';
|
|
3
3
|
import {
|
|
4
4
|
FLEX_CODE_MODE_TOOL_NAME,
|
|
5
|
+
FLEX_RESULT_METHOD,
|
|
5
6
|
FLEX_TOOL_CATALOG_LIMITS as limits,
|
|
6
7
|
FLEX_TOOL_CATALOG_TOOL_NAMES as toolNames,
|
|
7
8
|
type IFlexCatalogProposal,
|
|
@@ -66,6 +67,7 @@ interface IMethodEntry {
|
|
|
66
67
|
readonly declaration?: string;
|
|
67
68
|
readonly run: IFlexCatalogMethod['run'];
|
|
68
69
|
readonly onCall?: NonNullable<IFlexCatalogMethod['onCall']>;
|
|
70
|
+
readonly readOnly: boolean;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
interface ITypeEntry {
|
|
@@ -115,6 +117,7 @@ const declaredTypesLine = /^\/\/ Declares types: (.*)$/gm;
|
|
|
115
117
|
const identifierScanPattern = /[A-Za-z_$][A-Za-z0-9_$]*/g;
|
|
116
118
|
const methodKeys = new Set([
|
|
117
119
|
'method', 'summary', 'description', 'tags', 'aliases', 'examples', 'inputSchema', 'outputSchema', 'declaration', 'run', 'onCall',
|
|
120
|
+
'readOnly',
|
|
118
121
|
]);
|
|
119
122
|
const catalogKeys = new Set(['methods', 'types', 'vocabulary', 'onProposals']);
|
|
120
123
|
const typeKeys = new Set(['name', 'description', 'schema', 'declaration']);
|
|
@@ -136,7 +139,58 @@ const clearMatchMargin = 1.25;
|
|
|
136
139
|
type TSearchField = keyof typeof fieldWeights;
|
|
137
140
|
const bm25K1 = 1.2;
|
|
138
141
|
const bm25B = 0.75;
|
|
139
|
-
const coreCache = new WeakMap<object, Map<
|
|
142
|
+
const coreCache = new WeakMap<object, Map<string, Promise<FlexToolCatalogCore>>>();
|
|
143
|
+
|
|
144
|
+
/** The catalog with `results.get` added; a catalog of its own cannot define it. */
|
|
145
|
+
function withResultsMethod(catalog: IFlexToolCatalog<any>): IFlexToolCatalog<any> {
|
|
146
|
+
const methods = Array.isArray(catalog.methods) ? catalog.methods : [];
|
|
147
|
+
if (methods.some((method) => isPlainObject(method) && method.method === FLEX_RESULT_METHOD)) {
|
|
148
|
+
throw new FlexHarnessValidationError(`Tool catalog method "${FLEX_RESULT_METHOD}" is reserved for stored results.`);
|
|
149
|
+
}
|
|
150
|
+
return { ...catalog, methods: [...methods, resultsMethod] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `results.get`, which reads a stored result. FlexHarness serves its calls itself; the entry gives
|
|
155
|
+
* it a declaration and parameter validation like any method. Search and namespace listings leave it
|
|
156
|
+
* out: the result handle names it.
|
|
157
|
+
*/
|
|
158
|
+
const resultsMethod: IFlexCatalogMethod = {
|
|
159
|
+
method: FLEX_RESULT_METHOD,
|
|
160
|
+
summary: 'Read a page of a tool result that was too large to return whole.',
|
|
161
|
+
description: 'A result too large for the output budget comes back as { $flexType: "result", handle, summary, pages }. '
|
|
162
|
+
+ 'This reads one page of it, or of the value at `path`, a dotted path such as "items.3.lines". Each page holds '
|
|
163
|
+
+ 'whole array items, object entries or a slice of a string; a part too large for a page stands as '
|
|
164
|
+
+ '{ $flexType: "oversize", path } to read by its path.',
|
|
165
|
+
readOnly: true,
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
properties: {
|
|
169
|
+
handle: { type: 'string', minLength: 1, maxLength: 64, description: 'The handle of the stored result.' },
|
|
170
|
+
page: { type: 'integer', minimum: 1, description: 'The page to read, from 1. Defaults to 1.' },
|
|
171
|
+
path: { type: 'string', maxLength: 512, description: 'A dotted path into the result. Defaults to the whole result.' },
|
|
172
|
+
},
|
|
173
|
+
required: ['handle'],
|
|
174
|
+
additionalProperties: false,
|
|
175
|
+
},
|
|
176
|
+
outputSchema: {
|
|
177
|
+
type: 'object',
|
|
178
|
+
properties: {
|
|
179
|
+
handle: { type: 'string' },
|
|
180
|
+
path: { type: 'string' },
|
|
181
|
+
page: { type: 'integer' },
|
|
182
|
+
pages: { type: 'integer' },
|
|
183
|
+
items: { type: 'array', items: { type: 'integer' }, description: 'The array items the page holds, [from, to).' },
|
|
184
|
+
entries: { type: 'array', items: { type: 'integer' }, description: 'The object entries the page holds, [from, to).' },
|
|
185
|
+
chars: { type: 'array', items: { type: 'integer' }, description: 'The string characters the page holds, [from, to).' },
|
|
186
|
+
value: {},
|
|
187
|
+
},
|
|
188
|
+
required: ['handle', 'path', 'page', 'pages', 'value'],
|
|
189
|
+
},
|
|
190
|
+
run: () => {
|
|
191
|
+
throw new FlexHarnessValidationError(`${FLEX_RESULT_METHOD} is served by the session that stored the result.`);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
140
194
|
|
|
141
195
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
142
196
|
typeof value === 'object'
|
|
@@ -516,11 +570,17 @@ class FlexToolCatalogCore {
|
|
|
516
570
|
public readonly typeIdentifiers: ReadonlyMap<string, string>,
|
|
517
571
|
vocabulary: readonly IVocabularyEntry[],
|
|
518
572
|
public readonly onProposals: IFlexToolCatalog['onProposals'] | undefined,
|
|
573
|
+
/** Built-in methods that search and namespace listings leave out. */
|
|
574
|
+
public readonly unlisted: ReadonlySet<string> = new Set(),
|
|
519
575
|
) {
|
|
520
|
-
this.index = new CatalogSearchIndex([...methods.values()], vocabulary);
|
|
576
|
+
this.index = new CatalogSearchIndex([...methods.values()].filter((entry) => !unlisted.has(entry.path)), vocabulary);
|
|
521
577
|
}
|
|
522
578
|
|
|
523
|
-
public static async build(
|
|
579
|
+
public static async build(
|
|
580
|
+
catalog: unknown,
|
|
581
|
+
mode: TDeclarationMode,
|
|
582
|
+
unlisted: ReadonlySet<string> = new Set(),
|
|
583
|
+
): Promise<FlexToolCatalogCore> {
|
|
524
584
|
if (!isPlainObject(catalog)) throw new FlexHarnessValidationError('A tool catalog must be a plain object.');
|
|
525
585
|
const unsupported = Object.keys(catalog).find((key) => !catalogKeys.has(key));
|
|
526
586
|
if (unsupported) throw new FlexHarnessValidationError(`A tool catalog does not support "${unsupported}".`);
|
|
@@ -645,6 +705,7 @@ class FlexToolCatalogCore {
|
|
|
645
705
|
onProposals === undefined
|
|
646
706
|
? undefined
|
|
647
707
|
: (proposals, context) => (onProposals as NonNullable<IFlexToolCatalog['onProposals']>).call(catalog, proposals, context),
|
|
708
|
+
unlisted,
|
|
648
709
|
);
|
|
649
710
|
}
|
|
650
711
|
|
|
@@ -731,6 +792,9 @@ class FlexToolCatalogCore {
|
|
|
731
792
|
if (value.onCall !== undefined && typeof value.onCall !== 'function') {
|
|
732
793
|
throw new FlexHarnessValidationError(`${methodLabel} onCall must be a function.`);
|
|
733
794
|
}
|
|
795
|
+
if (value.readOnly !== undefined && typeof value.readOnly !== 'boolean') {
|
|
796
|
+
throw new FlexHarnessValidationError(`${methodLabel} readOnly must be a boolean.`);
|
|
797
|
+
}
|
|
734
798
|
if (value.inputSchema === undefined) throw new FlexHarnessValidationError(`${methodLabel} needs an inputSchema.`);
|
|
735
799
|
const input = await resolveCatalogSchema(value.inputSchema, `${methodLabel} inputSchema`);
|
|
736
800
|
const inputJson = input.json;
|
|
@@ -762,6 +826,7 @@ class FlexToolCatalogCore {
|
|
|
762
826
|
...(onCall === undefined ? {} : {
|
|
763
827
|
onCall: (params: unknown, context: IFlexCatalogRunContext) => onCall.call(value, params, context),
|
|
764
828
|
}),
|
|
829
|
+
readOnly: value.readOnly === true,
|
|
765
830
|
};
|
|
766
831
|
}
|
|
767
832
|
|
|
@@ -987,6 +1052,8 @@ export class FlexToolCatalog<TScope = unknown> {
|
|
|
987
1052
|
public static async create<TScope = unknown>(
|
|
988
1053
|
catalog: IFlexToolCatalog<TScope>,
|
|
989
1054
|
options: IFlexToolCatalogOptions = {},
|
|
1055
|
+
/** `results`: adds `results.get`, for a FlexHarness run that stores results. */
|
|
1056
|
+
builtIns: { results?: boolean } = {},
|
|
990
1057
|
): Promise<FlexToolCatalog<TScope>> {
|
|
991
1058
|
const normalized = normalizeToolCatalogOptions(options);
|
|
992
1059
|
const mode = normalized.declarations;
|
|
@@ -998,10 +1065,13 @@ export class FlexToolCatalog<TScope = unknown> {
|
|
|
998
1065
|
byMode = new Map();
|
|
999
1066
|
coreCache.set(catalog, byMode);
|
|
1000
1067
|
}
|
|
1001
|
-
|
|
1068
|
+
const key = builtIns.results ? `${mode}+results` : mode;
|
|
1069
|
+
let core = byMode.get(key);
|
|
1002
1070
|
if (!core) {
|
|
1003
|
-
core =
|
|
1004
|
-
|
|
1071
|
+
core = builtIns.results
|
|
1072
|
+
? FlexToolCatalogCore.build(withResultsMethod(catalog), mode, new Set([FLEX_RESULT_METHOD]))
|
|
1073
|
+
: FlexToolCatalogCore.build(catalog, mode);
|
|
1074
|
+
byMode.set(key, core);
|
|
1005
1075
|
}
|
|
1006
1076
|
return new FlexToolCatalog<TScope>(
|
|
1007
1077
|
await core,
|
|
@@ -1038,6 +1108,16 @@ export class FlexToolCatalog<TScope = unknown> {
|
|
|
1038
1108
|
return this.core.methods.has(path) && this.isAdmitted(path);
|
|
1039
1109
|
}
|
|
1040
1110
|
|
|
1111
|
+
/** Whether the method at `path` declared that it only reads. */
|
|
1112
|
+
public isReadOnly(path: string): boolean {
|
|
1113
|
+
return this.has(path) && this.core.methods.get(path)!.readOnly;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** Whether the method at `path` decides each call with `onCall`. */
|
|
1117
|
+
public decides(path: string): boolean {
|
|
1118
|
+
return this.has(path) && this.core.methods.get(path)!.onCall !== undefined;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1041
1121
|
/**
|
|
1042
1122
|
* The built-in tools as the model sees them: the three catalog tools, and `run_code` in code
|
|
1043
1123
|
* mode. Their text depends only on the catalog and its options.
|
|
@@ -1330,7 +1410,7 @@ export class FlexToolCatalog<TScope = unknown> {
|
|
|
1330
1410
|
};
|
|
1331
1411
|
node('');
|
|
1332
1412
|
for (const entry of this.core.methods.values()) {
|
|
1333
|
-
if (!this.isAdmitted(entry.path)) continue;
|
|
1413
|
+
if (!this.isAdmitted(entry.path) || this.core.unlisted.has(entry.path)) continue;
|
|
1334
1414
|
node(entry.namespace).methods.push(entry);
|
|
1335
1415
|
const segments = entry.path.split('.');
|
|
1336
1416
|
for (let depth = 1; depth < segments.length; depth++) {
|
package/ts/interfaces.ts
CHANGED
|
@@ -139,6 +139,11 @@ export interface IFlexToolMessagePart {
|
|
|
139
139
|
childSessionId?: string;
|
|
140
140
|
/** For a catalog call a `run_code` program made: the `run_code` call's ID. */
|
|
141
141
|
parentToolCallId?: string;
|
|
142
|
+
/**
|
|
143
|
+
* For a read-only call an identical read earlier in the turn answered: that call's ID. The call
|
|
144
|
+
* did not run; its output is the earlier call's.
|
|
145
|
+
*/
|
|
146
|
+
deduplicatedFrom?: string;
|
|
142
147
|
model?: IFlexModelIdentity;
|
|
143
148
|
/** ISO time the tool call began. Absent on parts recorded before part times existed. */
|
|
144
149
|
startedAt?: string;
|
|
@@ -660,6 +665,14 @@ export interface IFlexToolHandle<TScope = unknown> {
|
|
|
660
665
|
* every run: FlexHarness prepares a catalog once per object and treats it as immutable.
|
|
661
666
|
*/
|
|
662
667
|
catalog?: IFlexToolCatalog<TScope>;
|
|
668
|
+
/**
|
|
669
|
+
* Tells read-only calls apart, by the tool's name or a catalog method's path and the call's
|
|
670
|
+
* input or params: they have no effect but their result. They run concurrently, may start while
|
|
671
|
+
* the model still streams its response, and a turn answers identical ones once. Every other call
|
|
672
|
+
* runs in the order the model made it, after the calls before it. A catalog method's `readOnly`
|
|
673
|
+
* declaration needs no classifier. It may be asked more than once about one call.
|
|
674
|
+
*/
|
|
675
|
+
isReadOnlyToolCall?(call: { toolName: string; input: unknown }): boolean;
|
|
663
676
|
close?(): Promise<void> | void;
|
|
664
677
|
}
|
|
665
678
|
|
|
@@ -669,6 +682,29 @@ export interface IFlexToolProvider<TScope> {
|
|
|
669
682
|
): Promise<IFlexToolHandle<TScope> | undefined> | IFlexToolHandle<TScope> | undefined;
|
|
670
683
|
}
|
|
671
684
|
|
|
685
|
+
/** The catalog method that reads stored results, reserved while a run stores results. */
|
|
686
|
+
export const FLEX_RESULT_METHOD = 'results.get';
|
|
687
|
+
|
|
688
|
+
export const FLEX_RESULT_LIMITS = Object.freeze({
|
|
689
|
+
/** The most of one output a result stores; beyond it the stored value is truncated. */
|
|
690
|
+
maxStoredBytes: 8 * 1024 * 1024,
|
|
691
|
+
/** The size of the summary the model gets in place of the output. */
|
|
692
|
+
summaryBytes: 2 * 1024,
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
/** What the model gets in place of an output over the budget. */
|
|
696
|
+
export interface IFlexResultHandle {
|
|
697
|
+
$flexType: 'result';
|
|
698
|
+
handle: string;
|
|
699
|
+
/** The stored output's size as JSON. */
|
|
700
|
+
bytes: number;
|
|
701
|
+
/** The pages `results.get` reads the whole output in. */
|
|
702
|
+
pages: number;
|
|
703
|
+
summary: TJsonValue;
|
|
704
|
+
/** How to read it. */
|
|
705
|
+
read: string;
|
|
706
|
+
}
|
|
707
|
+
|
|
672
708
|
/** The names of the built-in catalog tools, reserved while a run has a catalog. */
|
|
673
709
|
export const FLEX_TOOL_CATALOG_TOOL_NAMES = Object.freeze({
|
|
674
710
|
search: 'search_tools',
|
|
@@ -826,6 +862,14 @@ export interface IFlexCatalogMethod<TScope = unknown, TParams = any, TResult = u
|
|
|
826
862
|
params: TParams,
|
|
827
863
|
context: IFlexCatalogRunContext<TScope>,
|
|
828
864
|
): TResult | PromiseLike<TResult> | AsyncIterable<TResult>;
|
|
865
|
+
/**
|
|
866
|
+
* The method only reads: it has no effect but its result. Its calls run concurrently with other
|
|
867
|
+
* read-only calls, `execute` may start one while the model still streams its response, and a
|
|
868
|
+
* turn answers identical calls once. Calls of other methods, and of a method with `onCall`, run
|
|
869
|
+
* in the order the model made them. A method that asks for permission is best left undeclared:
|
|
870
|
+
* the prompts of concurrent calls would overlap.
|
|
871
|
+
*/
|
|
872
|
+
readOnly?: boolean;
|
|
829
873
|
/**
|
|
830
874
|
* Decides each validated call before it runs: `run` (the default without this hook), `propose`
|
|
831
875
|
* to hand it to the catalog's `onProposals` instead of running it, or `{ deny }` to refuse it.
|
|
@@ -1386,6 +1430,16 @@ export interface IFlexToolJobStoreProvider {
|
|
|
1386
1430
|
deleteSession(storageKey: string, sessionId: string): Promise<void>;
|
|
1387
1431
|
}
|
|
1388
1432
|
|
|
1433
|
+
/**
|
|
1434
|
+
* Tool outputs over the output budget, stored whole for the session that produced them. A result
|
|
1435
|
+
* is written once under its handle and read by `results.get`.
|
|
1436
|
+
*/
|
|
1437
|
+
export interface IFlexResultStore {
|
|
1438
|
+
save(storageKey: string, sessionId: string, handle: string, value: TJsonValue): Promise<void>;
|
|
1439
|
+
load(storageKey: string, sessionId: string, handle: string): Promise<TJsonValue | undefined>;
|
|
1440
|
+
deleteSession(storageKey: string, sessionId: string): Promise<void>;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1389
1443
|
export interface IFlexHarnessStores {
|
|
1390
1444
|
scopes: IFlexScopeStore;
|
|
1391
1445
|
projections: IFlexProjectionStore;
|
|
@@ -1393,6 +1447,12 @@ export interface IFlexHarnessStores {
|
|
|
1393
1447
|
projectManagement: IFlexProjectManagementStore;
|
|
1394
1448
|
agentEvents: IFlexAgentEventStoreProvider;
|
|
1395
1449
|
jobs: IFlexToolJobStoreProvider;
|
|
1450
|
+
/**
|
|
1451
|
+
* Stores the outputs of a run with a tool catalog that exceed the output budget: the model gets a
|
|
1452
|
+
* summary and a handle it reads with `results.get`. Without it, such outputs are truncated to
|
|
1453
|
+
* the budget. The built-in stores provide it.
|
|
1454
|
+
*/
|
|
1455
|
+
results?: IFlexResultStore;
|
|
1396
1456
|
}
|
|
1397
1457
|
|
|
1398
1458
|
export interface IFlexJsonLimits {
|
package/ts/plugins.ts
CHANGED
|
@@ -97,6 +97,7 @@ import {
|
|
|
97
97
|
AgentPendingInputQueue,
|
|
98
98
|
AgentPendingInputRejectedError,
|
|
99
99
|
AgentSession,
|
|
100
|
+
AgentToolCallScheduler,
|
|
100
101
|
buildModelMessages,
|
|
101
102
|
ToolJobStoreConflictError,
|
|
102
103
|
createAgentEvent,
|
|
@@ -115,6 +116,7 @@ export {
|
|
|
115
116
|
AgentPendingInputQueue,
|
|
116
117
|
AgentPendingInputRejectedError,
|
|
117
118
|
AgentSession,
|
|
119
|
+
AgentToolCallScheduler,
|
|
118
120
|
buildModelMessages,
|
|
119
121
|
ToolJobStoreConflictError,
|
|
120
122
|
createAgentEvent,
|
|
@@ -142,6 +144,7 @@ export type {
|
|
|
142
144
|
IAgentSession,
|
|
143
145
|
IAgentSessionOptions,
|
|
144
146
|
IAgentToolCallUpdateEvent,
|
|
147
|
+
IAgentToolExecutionOptions,
|
|
145
148
|
IAgentRuntimeEventPayload,
|
|
146
149
|
IGenerationBegunEvent,
|
|
147
150
|
IGenerationExecutionCompletedEvent,
|
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.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);
|