@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
@@ -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';
@@ -50,6 +52,7 @@ import {
50
52
  providerKey,
51
53
  validateExpectedRevision,
52
54
  validateIdentifier,
55
+ validateResultIndex,
53
56
  validateProjectManagementWriteContext,
54
57
  validateSnapshotSave,
55
58
  } from './utils.storerules.js';
@@ -336,6 +339,36 @@ class InMemoryToolJobStoreProvider implements IFlexToolJobStoreProvider {
336
339
  }
337
340
  }
338
341
 
342
+ class InMemoryResultStore implements IFlexResultStore {
343
+ private readonly sessions = new Map<string, Map<string, TJsonValue>>();
344
+
345
+ public async save(storageKey: string, sessionId: string, handle: string, value: TJsonValue): Promise<void> {
346
+ validateIdentifier(handle, 'handle');
347
+ const key = providerKey(storageKey, sessionId);
348
+ const results = this.sessions.get(key) ?? new Map<string, TJsonValue>();
349
+ if (results.has(handle)) throw new FlexHarnessStoreConflictError(`${key}:${handle}`, 0, 1);
350
+ results.set(handle, cloneSerializable(value));
351
+ this.sessions.set(key, results);
352
+ }
353
+
354
+ public async load(storageKey: string, sessionId: string, handle: string): Promise<TJsonValue | undefined> {
355
+ const value = this.sessions.get(providerKey(storageKey, sessionId))?.get(handle);
356
+ return value === undefined ? undefined : cloneSerializable(value);
357
+ }
358
+
359
+ public async list(storageKey: string, sessionId: string): Promise<string[]> {
360
+ return [...this.sessions.get(providerKey(storageKey, sessionId))?.keys() ?? []];
361
+ }
362
+
363
+ public async delete(storageKey: string, sessionId: string, handle: string): Promise<void> {
364
+ this.sessions.get(providerKey(storageKey, sessionId))?.delete(handle);
365
+ }
366
+
367
+ public async deleteSession(storageKey: string, sessionId: string): Promise<void> {
368
+ this.sessions.delete(providerKey(storageKey, sessionId));
369
+ }
370
+ }
371
+
339
372
  export class InMemoryFlexHarnessStores implements IFlexHarnessStores {
340
373
  public readonly scopes: IFlexScopeStore = new InMemoryScopeStore();
341
374
  public readonly projections: IFlexProjectionStore = new InMemoryProjectionStore();
@@ -343,6 +376,7 @@ export class InMemoryFlexHarnessStores implements IFlexHarnessStores {
343
376
  public readonly agentEvents: IFlexAgentEventStoreProvider = new InMemoryAgentEventStoreProvider();
344
377
  public readonly jobs: IFlexToolJobStoreProvider = new InMemoryToolJobStoreProvider();
345
378
  public readonly projectManagement = new InMemoryFlexProjectManagementStore();
379
+ public readonly results: IFlexResultStore = new InMemoryResultStore();
346
380
  }
347
381
 
348
382
  const jsonDomains = [
@@ -353,6 +387,7 @@ const jsonDomains = [
353
387
  'events',
354
388
  'archives',
355
389
  'jobs',
390
+ 'results',
356
391
  ] as const;
357
392
  type TJsonDomain = (typeof jsonDomains)[number];
358
393
 
@@ -433,6 +468,21 @@ class JsonFileRoot {
433
468
  );
434
469
  }
435
470
 
471
+ public resultPath(storageKey: string, sessionId: string, handle: string): string {
472
+ validateIdentifier(handle, 'handle');
473
+ return plugins.path.join(this.resultSessionPath(storageKey, sessionId), `${this.digest(handle)}.json`);
474
+ }
475
+
476
+ public resultIndexPath(storageKey: string, sessionId: string): string {
477
+ return plugins.path.join(this.resultSessionPath(storageKey, sessionId), 'index.json');
478
+ }
479
+
480
+ public resultSessionPath(storageKey: string, sessionId: string): string {
481
+ validateIdentifier(storageKey, 'storageKey');
482
+ validateIdentifier(sessionId, 'sessionId');
483
+ return plugins.path.join(this.directory, 'results', this.digest(storageKey), this.digest(sessionId));
484
+ }
485
+
436
486
  public eventQueueKey(storageKey: string, sessionId: string): string {
437
487
  return `event:${this.sessionPath('events', storageKey, sessionId)}`;
438
488
  }
@@ -851,6 +901,63 @@ class JsonPermissionStore implements IFlexPermissionStore {
851
901
  }
852
902
  }
853
903
 
904
+ /**
905
+ * One file per result and an index of the session's handles, oldest first. Every operation of a
906
+ * session runs in the session's queue, so the index follows the files.
907
+ */
908
+ class JsonResultStore implements IFlexResultStore {
909
+ constructor(private readonly root: JsonFileRoot) {}
910
+
911
+ private inSessionQueue<T>(storageKey: string, sessionId: string, operation: () => Promise<T>): Promise<T> {
912
+ return JsonFileRoot.inQueue(`results:${this.root.resultSessionPath(storageKey, sessionId)}`, operation);
913
+ }
914
+
915
+ private readIndex(storageKey: string, sessionId: string): Promise<string[]> {
916
+ return this.root.read(this.root.resultIndexPath(storageKey, sessionId), validateResultIndex)
917
+ .then((index) => index?.handles ?? []);
918
+ }
919
+
920
+ public save(storageKey: string, sessionId: string, handle: string, value: TJsonValue): Promise<void> {
921
+ const filePath = this.root.resultPath(storageKey, sessionId, handle);
922
+ return this.inSessionQueue(storageKey, sessionId, async () => {
923
+ if (await this.root.read(filePath, (stored) => stored) !== undefined) {
924
+ throw new FlexHarnessStoreConflictError(`${providerKey(storageKey, sessionId)}:${handle}`, 0, 1);
925
+ }
926
+ await this.root.write(filePath, value);
927
+ const handles = await this.readIndex(storageKey, sessionId);
928
+ await this.root.write(this.root.resultIndexPath(storageKey, sessionId), { handles: [...handles, handle] });
929
+ });
930
+ }
931
+
932
+ public load(storageKey: string, sessionId: string, handle: string): Promise<TJsonValue | undefined> {
933
+ const filePath = this.root.resultPath(storageKey, sessionId, handle);
934
+ return this.inSessionQueue(storageKey, sessionId, () => this.root.read(filePath, (value) => {
935
+ assertJsonSerializable(value);
936
+ return value as TJsonValue;
937
+ }));
938
+ }
939
+
940
+ public list(storageKey: string, sessionId: string): Promise<string[]> {
941
+ return this.inSessionQueue(storageKey, sessionId, () => this.readIndex(storageKey, sessionId));
942
+ }
943
+
944
+ public delete(storageKey: string, sessionId: string, handle: string): Promise<void> {
945
+ return this.inSessionQueue(storageKey, sessionId, async () => {
946
+ const handles = await this.readIndex(storageKey, sessionId);
947
+ await this.root.write(
948
+ this.root.resultIndexPath(storageKey, sessionId),
949
+ { handles: handles.filter((entry) => entry !== handle) },
950
+ );
951
+ await this.root.deleteFile(this.root.resultPath(storageKey, sessionId, handle));
952
+ });
953
+ }
954
+
955
+ public deleteSession(storageKey: string, sessionId: string): Promise<void> {
956
+ const directory = this.root.resultSessionPath(storageKey, sessionId);
957
+ return this.inSessionQueue(storageKey, sessionId, () => this.root.deleteDirectory(directory));
958
+ }
959
+ }
960
+
854
961
  class JsonProjectManagementStore implements IFlexProjectManagementStore {
855
962
  constructor(private readonly root: JsonFileRoot) {}
856
963
 
@@ -1186,6 +1293,7 @@ export class JsonFileFlexHarnessStores implements IFlexHarnessStores {
1186
1293
  public readonly agentEvents: IFlexAgentEventStoreProvider;
1187
1294
  public readonly jobs: IFlexToolJobStoreProvider;
1188
1295
  public readonly projectManagement: IFlexProjectManagementStore;
1296
+ public readonly results: IFlexResultStore;
1189
1297
  private readonly root: JsonFileRoot;
1190
1298
 
1191
1299
  constructor(options: IJsonFileFlexHarnessStoresOptions) {
@@ -1199,6 +1307,7 @@ export class JsonFileFlexHarnessStores implements IFlexHarnessStores {
1199
1307
  this.projectManagement = new JsonProjectManagementStore(this.root);
1200
1308
  this.agentEvents = new JsonAgentEventStoreProvider(this.root);
1201
1309
  this.jobs = new JsonToolJobStoreProvider(this.root);
1310
+ this.results = new JsonResultStore(this.root);
1202
1311
  }
1203
1312
 
1204
1313
  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<TDeclarationMode, Promise<FlexToolCatalogCore>>>();
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(catalog: unknown, mode: TDeclarationMode): Promise<FlexToolCatalogCore> {
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
 
@@ -842,10 +907,13 @@ class FlexToolCatalogCore {
842
907
  if (cached) return cached;
843
908
  let rendered: IRenderedDeclaration;
844
909
  if (entry.declaration !== undefined) {
845
- rendered = { text: entry.declaration.trim(), types: this.scanTypes(entry.declaration) };
846
- // A host's declaration text does not tell parameters from the result: every type it names is
847
- // declared with it.
848
- this.methodTypeSets.set(entry.path, { input: rendered.types, output: [] });
910
+ // A host's declaration names its parameters' types in its parameter list and its result's after
911
+ // it; text that is no plain function declaration declares every type it names with it.
912
+ const parts = signatureParts(entry.declaration, entry.name);
913
+ const input = this.scanTypes(parts?.params ?? entry.declaration);
914
+ const output = parts ? this.scanTypes(parts.result) : [];
915
+ rendered = { text: entry.declaration.trim(), types: [...new Set([...input, ...output])].sort(compareCodePoints) };
916
+ this.methodTypeSets.set(entry.path, { input, output });
849
917
  } else {
850
918
  const inputWriter = new SchemaDeclarationWriter(this.scopeContext(entry.inputScope));
851
919
  const members = inputWriter.objectMembers(entry.input.json);
@@ -879,9 +947,10 @@ class FlexToolCatalogCore {
879
947
  return rendered;
880
948
  }
881
949
 
950
+ /** The catalog types a declaration's code names; its comments and string literals name none. */
882
951
  private scanTypes(text: string, self?: string): string[] {
883
952
  const found = new Set<string>();
884
- for (const match of text.matchAll(identifierScanPattern)) {
953
+ for (const match of codeOf(text).matchAll(identifierScanPattern)) {
885
954
  if (match[0] !== self && this.types.has(match[0])) found.add(match[0]);
886
955
  }
887
956
  return [...found].sort(compareCodePoints);
@@ -980,6 +1049,16 @@ export class FlexToolCatalog<TScope = unknown> {
980
1049
  private readonly describeTopMatch: boolean,
981
1050
  ) {}
982
1051
 
1052
+ /**
1053
+ * The identifier a name from elsewhere, such as a file or an API's method, is declared under as a
1054
+ * path segment or a type: every other character `_`, `_` before a leading digit and after a
1055
+ * reserved word (`export-jobs` becomes `export_jobs`, `delete` becomes `delete_`). The catalog
1056
+ * maps type names that are no identifiers the same way.
1057
+ */
1058
+ public static identifierOf(name: string): string {
1059
+ return typeIdentifierOf(name);
1060
+ }
1061
+
983
1062
  /**
984
1063
  * Prepares `catalog`. Preparing the same object again returns the cached preparation, so a host
985
1064
  * that returns one catalog object for every run pays for it once.
@@ -987,6 +1066,8 @@ export class FlexToolCatalog<TScope = unknown> {
987
1066
  public static async create<TScope = unknown>(
988
1067
  catalog: IFlexToolCatalog<TScope>,
989
1068
  options: IFlexToolCatalogOptions = {},
1069
+ /** `results`: adds `results.get`, for a FlexHarness run that stores results. */
1070
+ builtIns: { results?: boolean } = {},
990
1071
  ): Promise<FlexToolCatalog<TScope>> {
991
1072
  const normalized = normalizeToolCatalogOptions(options);
992
1073
  const mode = normalized.declarations;
@@ -998,10 +1079,13 @@ export class FlexToolCatalog<TScope = unknown> {
998
1079
  byMode = new Map();
999
1080
  coreCache.set(catalog, byMode);
1000
1081
  }
1001
- let core = byMode.get(mode);
1082
+ const key = builtIns.results ? `${mode}+results` : mode;
1083
+ let core = byMode.get(key);
1002
1084
  if (!core) {
1003
- core = FlexToolCatalogCore.build(catalog, mode);
1004
- byMode.set(mode, core);
1085
+ core = builtIns.results
1086
+ ? FlexToolCatalogCore.build(withResultsMethod(catalog), mode, new Set([FLEX_RESULT_METHOD]))
1087
+ : FlexToolCatalogCore.build(catalog, mode);
1088
+ byMode.set(key, core);
1005
1089
  }
1006
1090
  return new FlexToolCatalog<TScope>(
1007
1091
  await core,
@@ -1038,6 +1122,16 @@ export class FlexToolCatalog<TScope = unknown> {
1038
1122
  return this.core.methods.has(path) && this.isAdmitted(path);
1039
1123
  }
1040
1124
 
1125
+ /** Whether the method at `path` declared that it only reads. */
1126
+ public isReadOnly(path: string): boolean {
1127
+ return this.has(path) && this.core.methods.get(path)!.readOnly;
1128
+ }
1129
+
1130
+ /** Whether the method at `path` decides each call with `onCall`. */
1131
+ public decides(path: string): boolean {
1132
+ return this.has(path) && this.core.methods.get(path)!.onCall !== undefined;
1133
+ }
1134
+
1041
1135
  /**
1042
1136
  * The built-in tools as the model sees them: the three catalog tools, and `run_code` in code
1043
1137
  * mode. Their text depends only on the catalog and its options.
@@ -1210,7 +1304,7 @@ export class FlexToolCatalog<TScope = unknown> {
1210
1304
  const unknownTypes = requestedTypes.filter((name) => identifierOf(name) === undefined).sort(compareCodePoints);
1211
1305
  const notes = [
1212
1306
  ...unknownMethods.map((path) => {
1213
- const suggestions = closeMatches(path, this.methods);
1307
+ const suggestions = closeMatches(path, this.listedMethods());
1214
1308
  return `Unknown method ${path}.${suggestions.length ? ` Did you mean ${suggestions.join(', ')}?` : ' Find methods with search_tools.'}`;
1215
1309
  }),
1216
1310
  ...unknownTypes.map((name) => {
@@ -1256,7 +1350,7 @@ export class FlexToolCatalog<TScope = unknown> {
1256
1350
  error: {
1257
1351
  error: 'unknown_method',
1258
1352
  method: path,
1259
- didYouMean: isNamespace ? [] : closeMatches(path, this.methods),
1353
+ didYouMean: isNamespace ? [] : closeMatches(path, this.listedMethods()),
1260
1354
  hint: isNamespace
1261
1355
  ? `${path} is a namespace; list it with search_tools({"namespace":"${path}"}).`
1262
1356
  : 'Find methods with search_tools.',
@@ -1313,6 +1407,11 @@ export class FlexToolCatalog<TScope = unknown> {
1313
1407
  return { method: input.method, params: input.params === undefined ? {} : input.params };
1314
1408
  }
1315
1409
 
1410
+ /** The method paths search and suggestions may name: built-ins such as `results.get` stay out. */
1411
+ private listedMethods(): string[] {
1412
+ return this.methods.filter((path) => !this.core.unlisted.has(path));
1413
+ }
1414
+
1316
1415
  private isAdmitted(path: string): boolean {
1317
1416
  return this.admitted === undefined || this.admitted.has(path);
1318
1417
  }
@@ -1330,7 +1429,7 @@ export class FlexToolCatalog<TScope = unknown> {
1330
1429
  };
1331
1430
  node('');
1332
1431
  for (const entry of this.core.methods.values()) {
1333
- if (!this.isAdmitted(entry.path)) continue;
1432
+ if (!this.isAdmitted(entry.path) || this.core.unlisted.has(entry.path)) continue;
1334
1433
  node(entry.namespace).methods.push(entry);
1335
1434
  const segments = entry.path.split('.');
1336
1435
  for (let depth = 1; depth < segments.length; depth++) {
@@ -1700,6 +1799,70 @@ export const catalogToolInputSchema = <TInput>(definition: IFlexCatalogToolDefin
1700
1799
  });
1701
1800
  };
1702
1801
 
1802
+ /** Declaration text with its comments and the contents of its string literals blanked. */
1803
+ const codeOf = (text: string): string => {
1804
+ let code = '';
1805
+ for (let index = 0; index < text.length; index++) {
1806
+ const char = text[index];
1807
+ if (char === '/' && text[index + 1] === '/') {
1808
+ const end = text.indexOf('\n', index);
1809
+ index = (end === -1 ? text.length : end) - 1;
1810
+ code += ' ';
1811
+ } else if (char === '/' && text[index + 1] === '*') {
1812
+ const end = text.indexOf('*/', index + 2);
1813
+ index = end === -1 ? text.length : end + 1;
1814
+ code += ' ';
1815
+ } else if (char === '"' || char === "'" || char === '`') {
1816
+ let end = index + 1;
1817
+ while (end < text.length && text[end] !== char) end += text[end] === '\\' ? 2 : 1;
1818
+ code += `${char}${char}`;
1819
+ index = end;
1820
+ } else {
1821
+ code += char;
1822
+ }
1823
+ }
1824
+ return code;
1825
+ };
1826
+
1827
+ /** The index of the bracket that closes the one at `open`, or -1. */
1828
+ const closingBracket = (code: string, open: number): number => {
1829
+ const pairs: Record<string, string> = { '(': ')', '<': '>', '{': '}', '[': ']' };
1830
+ const stack: string[] = [];
1831
+ for (let index = open; index < code.length; index++) {
1832
+ const char = code[index];
1833
+ if (char === '=' && code[index + 1] === '>') {
1834
+ index++;
1835
+ } else if (pairs[char] !== undefined && (char !== '<' || stack.length === 0 || stack[stack.length - 1] === '>')) {
1836
+ stack.push(pairs[char]);
1837
+ } else if (char === stack[stack.length - 1]) {
1838
+ stack.pop();
1839
+ if (stack.length === 0) return index;
1840
+ }
1841
+ }
1842
+ return -1;
1843
+ };
1844
+
1845
+ /**
1846
+ * A host method declaration's parameter list and the result after it, as code without comments, or
1847
+ * undefined where the text is no plain `function name(…): …` declaration.
1848
+ */
1849
+ const signatureParts = (declaration: string, name: string): { params: string; result: string } | undefined => {
1850
+ const code = codeOf(declaration);
1851
+ const start = new RegExp(`\\bfunction\\s+${name}\\s*`).exec(code);
1852
+ if (!start) return undefined;
1853
+ let open = start.index + start[0].length;
1854
+ if (code[open] === '<') {
1855
+ const typeParameters = closingBracket(code, open);
1856
+ if (typeParameters === -1) return undefined;
1857
+ open = typeParameters + 1;
1858
+ while (/\s/.test(code[open] ?? '')) open++;
1859
+ }
1860
+ if (code[open] !== '(') return undefined;
1861
+ const close = closingBracket(code, open);
1862
+ if (close === -1) return undefined;
1863
+ return { params: code.slice(open + 1, close), result: code.slice(close + 1) };
1864
+ };
1865
+
1703
1866
  /** A root-level method declaration gets `declare`, as a namespace member does not need it. */
1704
1867
  const withDeclare = (text: string): string => {
1705
1868
  const lines = text.split('\n');
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,31 @@ 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 results a session keeps; a new one beyond them deletes the oldest. */
692
+ maxResultsPerSession: 32,
693
+ /** The size of the summary the model gets in place of the output. */
694
+ summaryBytes: 2 * 1024,
695
+ });
696
+
697
+ /** What the model gets in place of an output over the budget. */
698
+ export interface IFlexResultHandle {
699
+ $flexType: 'result';
700
+ handle: string;
701
+ /** The stored output's size as JSON. */
702
+ bytes: number;
703
+ /** The pages `results.get` reads the whole output in. */
704
+ pages: number;
705
+ summary: TJsonValue;
706
+ /** How to read it. */
707
+ read: string;
708
+ }
709
+
672
710
  /** The names of the built-in catalog tools, reserved while a run has a catalog. */
673
711
  export const FLEX_TOOL_CATALOG_TOOL_NAMES = Object.freeze({
674
712
  search: 'search_tools',
@@ -684,7 +722,7 @@ export const FLEX_TOOL_CATALOG_LIMITS = Object.freeze({
684
722
  maxIdentifierCharacters: 128,
685
723
  maxSummaryCharacters: 120,
686
724
  maxDescriptionBytes: 16 * 1024,
687
- maxDeclarationBytes: 16 * 1024,
725
+ maxDeclarationBytes: 64 * 1024,
688
726
  maxTags: 16,
689
727
  maxTagCharacters: 48,
690
728
  maxAliases: 16,
@@ -826,6 +864,14 @@ export interface IFlexCatalogMethod<TScope = unknown, TParams = any, TResult = u
826
864
  params: TParams,
827
865
  context: IFlexCatalogRunContext<TScope>,
828
866
  ): TResult | PromiseLike<TResult> | AsyncIterable<TResult>;
867
+ /**
868
+ * The method only reads: it has no effect but its result. Its calls run concurrently with other
869
+ * read-only calls, `execute` may start one while the model still streams its response, and a
870
+ * turn answers identical calls once. Calls of other methods, and of a method with `onCall`, run
871
+ * in the order the model made them. A method that asks for permission is best left undeclared:
872
+ * the prompts of concurrent calls would overlap.
873
+ */
874
+ readOnly?: boolean;
829
875
  /**
830
876
  * Decides each validated call before it runs: `run` (the default without this hook), `propose`
831
877
  * to hand it to the catalog's `onProposals` instead of running it, or `{ deny }` to refuse it.
@@ -1386,6 +1432,22 @@ export interface IFlexToolJobStoreProvider {
1386
1432
  deleteSession(storageKey: string, sessionId: string): Promise<void>;
1387
1433
  }
1388
1434
 
1435
+ /**
1436
+ * Tool outputs over the output budget, stored whole for the session that produced them. A result
1437
+ * is written once under its handle and read by `results.get`; FlexHarness keeps a session's latest
1438
+ * `FLEX_RESULT_LIMITS.maxResultsPerSession` results and deletes older ones. A FlexHarness writes a
1439
+ * session's results one operation at a time, so a store need not order concurrent writes of one
1440
+ * session.
1441
+ */
1442
+ export interface IFlexResultStore {
1443
+ save(storageKey: string, sessionId: string, handle: string, value: TJsonValue): Promise<void>;
1444
+ load(storageKey: string, sessionId: string, handle: string): Promise<TJsonValue | undefined>;
1445
+ /** The handles of the session's results, oldest first. */
1446
+ list(storageKey: string, sessionId: string): Promise<string[]>;
1447
+ delete(storageKey: string, sessionId: string, handle: string): Promise<void>;
1448
+ deleteSession(storageKey: string, sessionId: string): Promise<void>;
1449
+ }
1450
+
1389
1451
  export interface IFlexHarnessStores {
1390
1452
  scopes: IFlexScopeStore;
1391
1453
  projections: IFlexProjectionStore;
@@ -1393,6 +1455,12 @@ export interface IFlexHarnessStores {
1393
1455
  projectManagement: IFlexProjectManagementStore;
1394
1456
  agentEvents: IFlexAgentEventStoreProvider;
1395
1457
  jobs: IFlexToolJobStoreProvider;
1458
+ /**
1459
+ * Stores the outputs of a run with a tool catalog that exceed the output budget: the model gets a
1460
+ * summary and a handle it reads with `results.get`. Without it, such outputs are truncated to
1461
+ * the budget. The built-in stores provide it.
1462
+ */
1463
+ results?: IFlexResultStore;
1396
1464
  }
1397
1465
 
1398
1466
  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,