@modelprofile.com/flexharness 10.15.0 → 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.
Files changed (46) 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 +33 -0
  4. package/dist_ts/classes.flexharness.js +328 -45
  5. package/dist_ts/classes.stores.d.ts +3 -1
  6. package/dist_ts/classes.stores.js +59 -1
  7. package/dist_ts/classes.toolcatalog.d.ts +9 -1
  8. package/dist_ts/classes.toolcatalog.js +82 -11
  9. package/dist_ts/interfaces.d.ts +59 -0
  10. package/dist_ts/interfaces.js +9 -1
  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.tooloutput.d.ts +5 -2
  18. package/dist_ts/utils.tooloutput.js +10 -3
  19. package/dist_ts_document/index.js +21 -8
  20. package/dist_ts_document/plugins.d.ts +2 -2
  21. package/dist_ts_document/plugins.js +3 -3
  22. package/dist_ts_media/index.js +14 -3
  23. package/dist_ts_media/plugins.d.ts +2 -2
  24. package/dist_ts_media/plugins.js +3 -3
  25. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +2 -1
  26. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +37 -4
  27. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +4 -3
  28. package/dist_ts_stores_nosqldb/classes.recordstore.js +2 -2
  29. package/package.json +3 -3
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/classes.codesandbox.ts +5 -1
  32. package/ts/classes.flexharness.ts +382 -44
  33. package/ts/classes.stores.ts +66 -0
  34. package/ts/classes.toolcatalog.ts +87 -7
  35. package/ts/interfaces.ts +60 -0
  36. package/ts/plugins.ts +3 -0
  37. package/ts/utils.json.ts +30 -0
  38. package/ts/utils.results.ts +164 -0
  39. package/ts/utils.tooloutput.ts +9 -2
  40. package/ts_document/index.ts +18 -7
  41. package/ts_document/plugins.ts +2 -2
  42. package/ts_document/readme.md +2 -0
  43. package/ts_media/index.ts +11 -2
  44. package/ts_media/plugins.ts +2 -2
  45. package/ts_stores_nosqldb/classes.nosqldbstores.ts +39 -2
  46. package/ts_stores_nosqldb/classes.recordstore.ts +6 -4
@@ -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<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
 
@@ -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
- let core = byMode.get(mode);
1068
+ const key = builtIns.results ? `${mode}+results` : mode;
1069
+ let core = byMode.get(key);
1002
1070
  if (!core) {
1003
- core = FlexToolCatalogCore.build(catalog, mode);
1004
- byMode.set(mode, core);
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
+ }
@@ -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);