@modelprofile.com/flexharness 10.13.0 → 10.15.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.
@@ -1,8 +1,13 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { FlexHarnessValidationError } from './errors.js';
3
3
  import {
4
+ FLEX_CODE_MODE_TOOL_NAME,
4
5
  FLEX_TOOL_CATALOG_LIMITS as limits,
5
6
  FLEX_TOOL_CATALOG_TOOL_NAMES as toolNames,
7
+ type IFlexCatalogProposal,
8
+ type IFlexCatalogProposalContext,
9
+ type IFlexCodeModeLimits,
10
+ type TFlexCatalogCallDecision,
6
11
  type IFlexCatalogDescribeInput,
7
12
  type IFlexCatalogDescribeResult,
8
13
  type IFlexCatalogMethod,
@@ -25,6 +30,7 @@ import {
25
30
  type IRenderedDeclaration,
26
31
  type TJsonSchemaNode,
27
32
  } from './utils.schemadeclarations.js';
33
+ import { normalizeToolCatalogOptions } from './utils.harnessoptions.js';
28
34
  import { validateUtf8String } from './utils.validation.js';
29
35
 
30
36
  type TValidationResult = { success: true; value: unknown } | { success: false; error: Error };
@@ -51,6 +57,7 @@ interface IMethodEntry {
51
57
  readonly summary: string;
52
58
  readonly description?: string;
53
59
  readonly tags: readonly string[];
60
+ readonly aliases: readonly string[];
54
61
  readonly examples: readonly string[];
55
62
  readonly input: ICatalogSchema;
56
63
  readonly inputScope: ISchemaScope;
@@ -58,10 +65,14 @@ interface IMethodEntry {
58
65
  readonly outputScope?: ISchemaScope;
59
66
  readonly declaration?: string;
60
67
  readonly run: IFlexCatalogMethod['run'];
68
+ readonly onCall?: NonNullable<IFlexCatalogMethod['onCall']>;
61
69
  }
62
70
 
63
71
  interface ITypeEntry {
72
+ /** The identifier the type is declared under. */
64
73
  readonly name: string;
74
+ /** The catalog's name for the type, where it is no identifier; `$ref`s name the type by it. */
75
+ readonly schemaName?: string;
65
76
  readonly description?: string;
66
77
  readonly declaration?: string;
67
78
  /** A catalog type's schema. */
@@ -76,6 +87,8 @@ interface IPreparedCall {
76
87
  readonly success: true;
77
88
  readonly method: string;
78
89
  readonly params: unknown;
90
+ /** The method's `onCall` decision for the call; `run` without the hook. */
91
+ decide(context: IFlexCatalogRunContext<any>): Promise<TFlexCatalogCallDecision>;
79
92
  run(context: IFlexCatalogRunContext<any>): ReturnType<IFlexCatalogMethod['run']>;
80
93
  }
81
94
 
@@ -98,17 +111,28 @@ const reservedWords = new Set([
98
111
  const controlPattern = /[\u0000-\u001f\u007f]/;
99
112
  const definitionRefPattern = /^#\/(\$defs|definitions)\/([^/]+)$/;
100
113
  const declaredTypesHeader = '// Declares types: ';
114
+ const declaredTypesLine = /^\/\/ Declares types: (.*)$/gm;
101
115
  const identifierScanPattern = /[A-Za-z_$][A-Za-z0-9_$]*/g;
102
- const describeFormats = new Set(['typescript', 'json-schema']);
103
116
  const methodKeys = new Set([
104
- 'method', 'summary', 'description', 'tags', 'examples', 'inputSchema', 'outputSchema', 'declaration', 'run',
117
+ 'method', 'summary', 'description', 'tags', 'aliases', 'examples', 'inputSchema', 'outputSchema', 'declaration', 'run', 'onCall',
105
118
  ]);
119
+ const catalogKeys = new Set(['methods', 'types', 'vocabulary', 'onProposals']);
106
120
  const typeKeys = new Set(['name', 'description', 'schema', 'declaration']);
107
121
  const stopWords = new Set([
108
- 'a', 'an', 'and', 'are', 'all', 'as', 'be', 'by', 'for', 'from', 'get', 'i', 'in', 'is', 'it', 'me', 'my',
122
+ 'a', 'an', 'and', 'are', 'all', 'as', 'be', 'by', 'for', 'from', 'i', 'in', 'is', 'it', 'me', 'my',
109
123
  'of', 'on', 'or', 'that', 'the', 'this', 'to', 'with',
110
124
  ]);
111
- const fieldWeights = { name: 3, namespace: 1.5, summary: 2, tags: 2, description: 1 } as const;
125
+ const fieldWeights = { name: 3, aliases: 3, namespace: 1.5, summary: 2, tags: 2, description: 1 } as const;
126
+ /**
127
+ * A method whose name's, or one alias's, every word the query names ranks this much higher: it is
128
+ * what was asked for.
129
+ */
130
+ const nameCoverageFactor = 1.5;
131
+ /**
132
+ * A top match leads clearly when it is the only one, or when the query names it and it scores this
133
+ * many times the runner-up.
134
+ */
135
+ const clearMatchMargin = 1.25;
112
136
  type TSearchField = keyof typeof fieldWeights;
113
137
  const bm25K1 = 1.2;
114
138
  const bm25B = 0.75;
@@ -207,6 +231,13 @@ const searchTerms = (text: string): string[] => text
207
231
  .filter(Boolean)
208
232
  .map(stem);
209
233
 
234
+ /** The search terms of a name or an alias, which must have some to be matched at all. */
235
+ const namedTermsOf = (text: string, label: string): string[] => {
236
+ const terms = searchTerms(text);
237
+ if (terms.length === 0) throw new FlexHarnessValidationError(`${label} must contain a letter or digit.`);
238
+ return terms;
239
+ };
240
+
210
241
  const editDistance = (left: string, right: string, limit: number): number => {
211
242
  if (Math.abs(left.length - right.length) > limit) return limit + 1;
212
243
  let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
@@ -292,6 +323,25 @@ const textOf = (
292
323
  return value;
293
324
  };
294
325
 
326
+ /** Optional text: absent, or a string that is empty or blank, counts as not given. */
327
+ const optionalTextOf = (
328
+ value: unknown,
329
+ label: string,
330
+ options: { maxCharacters?: number; maxBytes?: number; singleLine?: boolean },
331
+ ): string | undefined => value === undefined || (typeof value === 'string' && !value.trim())
332
+ ? undefined
333
+ : textOf(value, label, options);
334
+
335
+ /**
336
+ * The identifier a catalog type is declared under: its name where that is one, else the name with
337
+ * each character an identifier cannot hold replaced by `_`, a leading digit or a reserved word
338
+ * escaped with `_`.
339
+ */
340
+ const typeIdentifierOf = (name: string): string => {
341
+ const identifier = name.replace(/[^A-Za-z0-9_]/g, '_').replace(/^(?=\d)/, '_');
342
+ return reservedWords.has(identifier) ? `${identifier}_` : identifier;
343
+ };
344
+
295
345
  const textListOf = (
296
346
  value: unknown,
297
347
  label: string,
@@ -305,6 +355,30 @@ const textListOf = (
305
355
  return value.map((entry, index) => textOf(entry, `${label}[${index}]`, options));
306
356
  };
307
357
 
358
+ /** A vocabulary alias and the catalog words a query that says it searches for as well. */
359
+ interface IVocabularyEntry {
360
+ readonly alias: readonly string[];
361
+ readonly words: readonly string[];
362
+ }
363
+
364
+ const vocabularyOf = (value: unknown): IVocabularyEntry[] => {
365
+ if (value === undefined) return [];
366
+ if (!isPlainObject(value)) {
367
+ throw new FlexHarnessValidationError('A tool catalog vocabulary must be a plain object of words and their aliases.');
368
+ }
369
+ const words = Object.entries(value);
370
+ if (words.length > limits.maxVocabularyWords) {
371
+ throw new FlexHarnessValidationError(`A tool catalog vocabulary must have at most ${limits.maxVocabularyWords} words.`);
372
+ }
373
+ const textOptions = { maxCharacters: limits.maxAliasCharacters, singleLine: true };
374
+ return words.flatMap(([word, aliases]) => {
375
+ const label = `Tool catalog vocabulary word "${word}"`;
376
+ const terms = namedTermsOf(textOf(word, label, textOptions), label);
377
+ return textListOf(aliases, `${label} aliases`, limits.maxAliases, textOptions)
378
+ .map((alias, index) => ({ alias: namedTermsOf(alias, `${label} aliases[${index}]`), words: terms }));
379
+ });
380
+ };
381
+
308
382
  async function resolveCatalogSchema(schema: unknown, label: string): Promise<ICatalogSchema> {
309
383
  let json: unknown;
310
384
  let validate: TValidate | undefined;
@@ -346,10 +420,14 @@ async function resolveCatalogSchema(schema: unknown, label: string): Promise<ICa
346
420
  class CatalogSearchIndex {
347
421
  private readonly postings = new Map<string, Array<{ index: number; weight: number }>>();
348
422
  private readonly idf = new Map<string, number>();
423
+ /** The terms of each method's name and of each of its aliases. */
424
+ private readonly nameTerms: ReadonlyArray<ReadonlyArray<readonly string[]>>;
349
425
 
350
- constructor(private readonly entries: readonly IMethodEntry[]) {
426
+ constructor(private readonly entries: readonly IMethodEntry[], private readonly vocabulary: readonly IVocabularyEntry[]) {
427
+ this.nameTerms = entries.map((entry) => [entry.name, ...entry.aliases].map((name) => [...new Set(searchTerms(name))]));
351
428
  const fieldTexts = (entry: IMethodEntry): Record<TSearchField, string[]> => ({
352
429
  name: searchTerms(entry.name),
430
+ aliases: entry.aliases.flatMap(searchTerms),
353
431
  namespace: searchTerms(entry.namespace),
354
432
  summary: searchTerms(entry.summary),
355
433
  tags: entry.tags.flatMap(searchTerms),
@@ -384,11 +462,21 @@ class CatalogSearchIndex {
384
462
  }
385
463
  }
386
464
 
387
- /** Methods ranked by BM25 over name, namespace, summary, tags and description. */
388
- public search(query: string, accept: (entry: IMethodEntry) => boolean): IMethodEntry[] {
389
- const allTerms = [...new Set(searchTerms(query))];
465
+ /**
466
+ * Methods ranked by BM25 over name, aliases, namespace, summary, tags and description, best first.
467
+ * The query searches for the catalog words of every vocabulary alias it says as well.
468
+ */
469
+ public search(
470
+ query: string,
471
+ accept: (entry: IMethodEntry) => boolean,
472
+ ): Array<{ entry: IMethodEntry; score: number; named: boolean }> {
473
+ const said = new Set(searchTerms(query));
474
+ const allTerms = [...said];
475
+ for (const { alias, words } of this.vocabulary) {
476
+ if (alias.every((term) => said.has(term))) allTerms.push(...words);
477
+ }
390
478
  const meaningful = allTerms.filter((term) => !stopWords.has(term));
391
- const terms = meaningful.length > 0 ? meaningful : allTerms;
479
+ const terms = [...new Set(meaningful.length > 0 ? meaningful : allTerms)];
392
480
  const scores = new Map<number, number>();
393
481
  for (const term of terms) {
394
482
  const idf = this.idf.get(term);
@@ -398,20 +486,23 @@ class CatalogSearchIndex {
398
486
  }
399
487
  }
400
488
  const exact = query.trim().toLowerCase();
489
+ const queryTerms = new Set(allTerms);
401
490
  return [...scores.entries()]
402
491
  .map(([index, score]) => {
403
492
  const entry = this.entries[index];
404
- const bonus = entry.path.toLowerCase() === exact || entry.name.toLowerCase() === exact ? 1000 : 0;
405
- return { entry, score: score + bonus };
493
+ const bonus = [entry.path, entry.name, ...entry.aliases].some((name) => name.toLowerCase() === exact) ? 1000 : 0;
494
+ const named = this.nameTerms[index].some((name) => name.every((term) => queryTerms.has(term)));
495
+ return { entry, score: (named ? score * nameCoverageFactor : score) + bonus, named };
406
496
  })
407
497
  .filter(({ entry }) => accept(entry))
408
- .sort((left, right) => right.score - left.score || compareCodePoints(left.entry.path, right.entry.path))
409
- .map(({ entry }) => entry);
498
+ .sort((left, right) => right.score - left.score || compareCodePoints(left.entry.path, right.entry.path));
410
499
  }
411
500
  }
412
501
 
413
502
  class FlexToolCatalogCore {
414
503
  public readonly renderedMethods = new Map<string, IRenderedDeclaration>();
504
+ /** The named types each rendered method's parameters and result use. */
505
+ public readonly methodTypeSets = new Map<string, { input: string[]; output: string[] }>();
415
506
  public readonly renderedTypes = new Map<string, IRenderedDeclaration>();
416
507
  public readonly validators = new Map<string, plugins.JsonSchemaValidator>();
417
508
  public readonly selfContainedInputs = new Map<string, TJsonSchemaNode>();
@@ -421,27 +512,63 @@ class FlexToolCatalogCore {
421
512
  public readonly mode: TDeclarationMode,
422
513
  public readonly methods: ReadonlyMap<string, IMethodEntry>,
423
514
  public readonly types: ReadonlyMap<string, ITypeEntry>,
515
+ /** The identifier of each catalog type, by the name its `$ref`s use. */
516
+ public readonly typeIdentifiers: ReadonlyMap<string, string>,
517
+ vocabulary: readonly IVocabularyEntry[],
518
+ public readonly onProposals: IFlexToolCatalog['onProposals'] | undefined,
424
519
  ) {
425
- this.index = new CatalogSearchIndex([...methods.values()]);
520
+ this.index = new CatalogSearchIndex([...methods.values()], vocabulary);
426
521
  }
427
522
 
428
523
  public static async build(catalog: unknown, mode: TDeclarationMode): Promise<FlexToolCatalogCore> {
429
524
  if (!isPlainObject(catalog)) throw new FlexHarnessValidationError('A tool catalog must be a plain object.');
430
- const unsupported = Object.keys(catalog).find((key) => key !== 'methods' && key !== 'types');
525
+ const unsupported = Object.keys(catalog).find((key) => !catalogKeys.has(key));
431
526
  if (unsupported) throw new FlexHarnessValidationError(`A tool catalog does not support "${unsupported}".`);
527
+ const onProposals = catalog.onProposals;
528
+ if (onProposals !== undefined && typeof onProposals !== 'function') {
529
+ throw new FlexHarnessValidationError('A tool catalog onProposals must be a function.');
530
+ }
531
+ const vocabulary = vocabularyOf(catalog.vocabulary);
432
532
  if (!Array.isArray(catalog.methods) || catalog.methods.length > limits.maxMethods) {
433
533
  throw new FlexHarnessValidationError(`Tool catalog methods must be an array with at most ${limits.maxMethods} entries.`);
434
534
  }
435
535
  if (catalog.types !== undefined && (!Array.isArray(catalog.types) || catalog.types.length > limits.maxTypes)) {
436
536
  throw new FlexHarnessValidationError(`Tool catalog types must be an array with at most ${limits.maxTypes} entries.`);
437
537
  }
438
- const catalogTypes = new Map<string, ITypeEntry>();
538
+ const namedTypes = new Map<string, ITypeEntry>();
439
539
  for (const [index, type] of ((catalog.types ?? []) as unknown[]).entries()) {
440
540
  const entry = await FlexToolCatalogCore.normalizeType(type, `Tool catalog type ${index}`);
441
- if (catalogTypes.has(entry.name)) {
541
+ if (namedTypes.has(entry.name)) {
442
542
  throw new FlexHarnessValidationError(`Tool catalog type "${entry.name}" is defined twice.`);
443
543
  }
444
- catalogTypes.set(entry.name, entry);
544
+ namedTypes.set(entry.name, entry);
545
+ }
546
+ // A name that is an identifier keeps it; the others are mapped in code-point order, so a
547
+ // catalog always declares the same identifiers.
548
+ const typeIdentifiers = new Map<string, string>();
549
+ const taken = new Set<string>();
550
+ const names = [...namedTypes.keys()].sort(compareCodePoints);
551
+ for (const name of names) {
552
+ if (typeIdentifierOf(name) !== name) continue;
553
+ typeIdentifiers.set(name, name);
554
+ taken.add(name);
555
+ }
556
+ for (const name of names) {
557
+ if (typeIdentifiers.has(name)) continue;
558
+ const base = typeIdentifierOf(name);
559
+ let identifier = base;
560
+ for (let suffix = 2; taken.has(identifier); suffix++) identifier = `${base}_${suffix}`;
561
+ typeIdentifiers.set(name, identifier);
562
+ taken.add(identifier);
563
+ }
564
+ /** Catalog types by the name their `$ref`s use. */
565
+ const catalogTypes = new Map<string, ITypeEntry>();
566
+ for (const [name, entry] of namedTypes) {
567
+ const identifier = typeIdentifiers.get(name)!;
568
+ if (entry.declaration !== undefined && !new RegExp(`\\b(?:interface|type|enum|class)\\s+${identifier}\\b`).test(entry.declaration)) {
569
+ throw new FlexHarnessValidationError(`Tool catalog type "${name}" declaration must declare "${identifier}".`);
570
+ }
571
+ catalogTypes.set(name, { ...entry, name: identifier, ...(identifier === name ? {} : { schemaName: name }) });
445
572
  }
446
573
  const methodInputs: Array<Omit<IMethodEntry, 'inputScope' | 'outputScope'>> = [];
447
574
  for (const [index, method] of (catalog.methods as unknown[]).entries()) {
@@ -462,7 +589,7 @@ class FlexToolCatalogCore {
462
589
  }
463
590
  }
464
591
 
465
- const registry = new Map<string, ITypeEntry>(catalogTypes);
592
+ const registry = new Map<string, ITypeEntry>([...catalogTypes.values()].map((type) => [type.name, type]));
466
593
  const canonicalDefinitions = new Map<string, string>();
467
594
  const scopeOf = (root: TJsonSchemaNode, label: string, requireSchemas: boolean): ISchemaScope => {
468
595
  const scope: ISchemaScope = { root, localNames: new Map() };
@@ -493,7 +620,10 @@ class FlexToolCatalogCore {
493
620
  };
494
621
  for (const type of catalogTypes.values()) {
495
622
  if (!type.schema) continue;
496
- registry.set(type.name, { ...type, scope: scopeOf(type.schema.json, `Tool catalog type "${type.name}"`, true) });
623
+ registry.set(type.name, {
624
+ ...type,
625
+ scope: scopeOf(type.schema.json, `Tool catalog type "${type.schemaName ?? type.name}"`, true),
626
+ });
497
627
  }
498
628
  const methods = new Map<string, IMethodEntry>();
499
629
  for (const method of methodInputs) {
@@ -506,7 +636,16 @@ class FlexToolCatalogCore {
506
636
  });
507
637
  }
508
638
  const types = new Map([...registry.entries()].sort(([left], [right]) => compareCodePoints(left, right)));
509
- return new FlexToolCatalogCore(mode, methods, types);
639
+ return new FlexToolCatalogCore(
640
+ mode,
641
+ methods,
642
+ types,
643
+ typeIdentifiers,
644
+ vocabulary,
645
+ onProposals === undefined
646
+ ? undefined
647
+ : (proposals, context) => (onProposals as NonNullable<IFlexToolCatalog['onProposals']>).call(catalog, proposals, context),
648
+ );
510
649
  }
511
650
 
512
651
  private static async normalizeType(value: unknown, label: string): Promise<ITypeEntry> {
@@ -514,21 +653,16 @@ class FlexToolCatalogCore {
514
653
  const unsupported = Object.keys(value).find((key) => !typeKeys.has(key));
515
654
  if (unsupported) throw new FlexHarnessValidationError(`${label} does not support "${unsupported}".`);
516
655
  const name = textOf(value.name, `${label} name`, { maxCharacters: limits.maxIdentifierCharacters, singleLine: true });
517
- if (!typeNamePattern.test(name) || reservedWords.has(name)) {
518
- throw new FlexHarnessValidationError(`${label} name must be an identifier that is not a reserved word.`);
519
- }
656
+ if (/\s/.test(name)) throw new FlexHarnessValidationError(`${label} name must not contain whitespace.`);
520
657
  if (value.schema === undefined && value.declaration === undefined) {
521
658
  throw new FlexHarnessValidationError(`Tool catalog type "${name}" needs a schema or a declaration.`);
522
659
  }
523
- const description = value.description === undefined
524
- ? undefined
525
- : textOf(value.description, `Tool catalog type "${name}" description`, { maxBytes: limits.maxDescriptionBytes });
660
+ const description = optionalTextOf(value.description, `Tool catalog type "${name}" description`, {
661
+ maxBytes: limits.maxDescriptionBytes,
662
+ })?.trim();
526
663
  const declaration = value.declaration === undefined
527
664
  ? undefined
528
665
  : textOf(value.declaration, `Tool catalog type "${name}" declaration`, { maxBytes: limits.maxDeclarationBytes });
529
- if (declaration !== undefined && !new RegExp(`\\b(?:interface|type|enum|class)\\s+${name}\\b`).test(declaration)) {
530
- throw new FlexHarnessValidationError(`Tool catalog type "${name}" declaration must declare "${name}".`);
531
- }
532
666
  const schema = value.schema === undefined
533
667
  ? undefined
534
668
  : await resolveCatalogSchema(value.schema, `Tool catalog type "${name}" schema`);
@@ -573,12 +707,16 @@ class FlexToolCatalogCore {
573
707
  const summary = textOf(value.summary, `${methodLabel} summary`, {
574
708
  maxCharacters: limits.maxSummaryCharacters, singleLine: true,
575
709
  }).trim();
576
- const description = value.description === undefined
577
- ? undefined
578
- : textOf(value.description, `${methodLabel} description`, { maxBytes: limits.maxDescriptionBytes }).trim();
710
+ const description = optionalTextOf(value.description, `${methodLabel} description`, {
711
+ maxBytes: limits.maxDescriptionBytes,
712
+ })?.trim();
579
713
  const tags = textListOf(value.tags, `${methodLabel} tags`, limits.maxTags, {
580
714
  maxCharacters: limits.maxTagCharacters, singleLine: true,
581
715
  });
716
+ const aliases = textListOf(value.aliases, `${methodLabel} aliases`, limits.maxAliases, {
717
+ maxCharacters: limits.maxAliasCharacters, singleLine: true,
718
+ });
719
+ aliases.forEach((alias, index) => namedTermsOf(alias, `${methodLabel} aliases[${index}]`));
582
720
  const examples = textListOf(value.examples, `${methodLabel} examples`, limits.maxExamples, {
583
721
  maxBytes: limits.maxExampleBytes,
584
722
  });
@@ -590,6 +728,9 @@ class FlexToolCatalogCore {
590
728
  throw new FlexHarnessValidationError(`${methodLabel} declaration must declare "function ${name}(".`);
591
729
  }
592
730
  if (typeof value.run !== 'function') throw new FlexHarnessValidationError(`${methodLabel} run must be a function.`);
731
+ if (value.onCall !== undefined && typeof value.onCall !== 'function') {
732
+ throw new FlexHarnessValidationError(`${methodLabel} onCall must be a function.`);
733
+ }
593
734
  if (value.inputSchema === undefined) throw new FlexHarnessValidationError(`${methodLabel} needs an inputSchema.`);
594
735
  const input = await resolveCatalogSchema(value.inputSchema, `${methodLabel} inputSchema`);
595
736
  const inputJson = input.json;
@@ -604,6 +745,7 @@ class FlexToolCatalogCore {
604
745
  ? undefined
605
746
  : await resolveCatalogSchema(value.outputSchema, `${methodLabel} outputSchema`);
606
747
  const run = value.run as IFlexCatalogMethod['run'];
748
+ const onCall = value.onCall as IFlexCatalogMethod['onCall'];
607
749
  return {
608
750
  path,
609
751
  namespace: segments.slice(0, -1).join('.'),
@@ -611,11 +753,15 @@ class FlexToolCatalogCore {
611
753
  summary,
612
754
  ...(description ? { description } : {}),
613
755
  tags: Object.freeze(tags),
756
+ aliases: Object.freeze(aliases),
614
757
  examples: Object.freeze(examples),
615
758
  input,
616
759
  ...(output ? { output } : {}),
617
760
  ...(declaration === undefined ? {} : { declaration }),
618
761
  run: (params, context) => run.call(value, params, context),
762
+ ...(onCall === undefined ? {} : {
763
+ onCall: (params: unknown, context: IFlexCatalogRunContext) => onCall.call(value, params, context),
764
+ }),
619
765
  };
620
766
  }
621
767
 
@@ -660,7 +806,7 @@ class FlexToolCatalogCore {
660
806
  const local = scope.localNames.get(`${match[1]}/${key}`);
661
807
  if (local) return local;
662
808
  if (resolvePointer(scope.root, ref) !== undefined) return undefined;
663
- return this.types.has(key) ? key : undefined;
809
+ return this.typeIdentifiers.get(key);
664
810
  },
665
811
  resolve: (ref: string): TJsonSchemaNode | undefined => {
666
812
  const target = resolvePointer(scope.root, ref);
@@ -674,12 +820,17 @@ class FlexToolCatalogCore {
674
820
  if (cached) return cached;
675
821
  const type = this.types.get(name)!;
676
822
  let rendered: IRenderedDeclaration;
823
+ const schemaNote = type.schemaName === undefined ? undefined : `Named "${type.schemaName}" in the schema.`;
677
824
  if (type.declaration !== undefined) {
678
- rendered = { text: type.declaration.trim(), types: this.scanTypes(type.declaration, name) };
825
+ rendered = {
826
+ text: `${schemaNote === undefined ? '' : docBlock(schemaNote, '')}${type.declaration.trim()}`,
827
+ types: this.scanTypes(type.declaration, name),
828
+ };
679
829
  } else {
680
830
  const schema = type.definition ?? type.schema!.json;
681
831
  const writer = new SchemaDeclarationWriter(this.scopeContext(type.scope!));
682
- const text = writer.namedDeclaration(name, schema, type.description);
832
+ const description = [type.description, schemaNote].filter((part) => part !== undefined).join('\n\n');
833
+ const text = writer.namedDeclaration(name, schema, description || undefined);
683
834
  rendered = { text, types: writer.usedTypes.filter((used) => used !== name) };
684
835
  }
685
836
  this.renderedTypes.set(name, rendered);
@@ -692,6 +843,9 @@ class FlexToolCatalogCore {
692
843
  let rendered: IRenderedDeclaration;
693
844
  if (entry.declaration !== undefined) {
694
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: [] });
695
849
  } else {
696
850
  const inputWriter = new SchemaDeclarationWriter(this.scopeContext(entry.inputScope));
697
851
  const members = inputWriter.objectMembers(entry.input.json);
@@ -716,6 +870,10 @@ class FlexToolCatalogCore {
716
870
  text: `${docBlock(lines.join('\n'), '')}${signature}`,
717
871
  types: [...new Set([...inputWriter.usedTypes, ...(outputWriter?.usedTypes ?? [])])].sort(compareCodePoints),
718
872
  };
873
+ this.methodTypeSets.set(entry.path, {
874
+ input: [...new Set(inputWriter.usedTypes)].sort(compareCodePoints),
875
+ output: [...new Set(outputWriter?.usedTypes ?? [])].sort(compareCodePoints),
876
+ });
719
877
  }
720
878
  this.renderedMethods.set(entry.path, rendered);
721
879
  return rendered;
@@ -729,6 +887,28 @@ class FlexToolCatalogCore {
729
887
  return [...found].sort(compareCodePoints);
730
888
  }
731
889
 
890
+ /** The named types a method's parameters and result use. */
891
+ public methodTypes(entry: IMethodEntry): { input: string[]; output: string[] } {
892
+ this.renderMethod(entry);
893
+ return this.methodTypeSets.get(entry.path)!;
894
+ }
895
+
896
+ /** `names` and the types they use up to `depth` levels, `names` being the first, sorted. */
897
+ public typeLevels(names: Iterable<string>, depth: number, stop: ReadonlySet<string> = new Set()): string[] {
898
+ const result = new Set<string>();
899
+ let level = [...names];
900
+ for (let current = 0; current < depth && level.length > 0; current++) {
901
+ const next: string[] = [];
902
+ for (const name of level) {
903
+ if (result.has(name) || stop.has(name) || !this.types.has(name)) continue;
904
+ result.add(name);
905
+ next.push(...this.renderType(name).types);
906
+ }
907
+ level = next;
908
+ }
909
+ return [...result].sort(compareCodePoints);
910
+ }
911
+
732
912
  /** `names` and every type they use, transitively, sorted. */
733
913
  public typeClosure(names: Iterable<string>, stop: ReadonlySet<string> = new Set()): string[] {
734
914
  const result = new Set<string>();
@@ -755,7 +935,8 @@ class FlexToolCatalogCore {
755
935
  const ref = queue.pop()!;
756
936
  const match = definitionRefPattern.exec(ref);
757
937
  if (!match || resolvePointer(root, ref) !== undefined) continue;
758
- const type = this.types.get(decodePointerToken(match[2]));
938
+ const identifier = this.typeIdentifiers.get(decodePointerToken(match[2]));
939
+ const type = identifier === undefined ? undefined : this.types.get(identifier);
759
940
  if (!type?.schema) continue;
760
941
  const container = (isSchemaRecord(root[match[1]]) ? root[match[1]] : (root[match[1]] = {})) as Record<string, unknown>;
761
942
  container[decodePointerToken(match[2])] = withoutDraft(structuredClone(type.schema.json) as TJsonSchemaNode);
@@ -791,6 +972,12 @@ export class FlexToolCatalog<TScope = unknown> {
791
972
  private constructor(
792
973
  private readonly core: FlexToolCatalogCore,
793
974
  private readonly admitted: ReadonlySet<string> | undefined,
975
+ /** The limits of `run_code`; absent while code mode is off. */
976
+ public readonly codeMode: Readonly<IFlexCodeModeLimits> | undefined,
977
+ /** Levels of a result's named types a TypeScript declaration declares. */
978
+ private readonly outputTypeDepth: number,
979
+ /** `search` follows a clear top match with its declaration. */
980
+ private readonly describeTopMatch: boolean,
794
981
  ) {}
795
982
 
796
983
  /**
@@ -801,10 +988,8 @@ export class FlexToolCatalog<TScope = unknown> {
801
988
  catalog: IFlexToolCatalog<TScope>,
802
989
  options: IFlexToolCatalogOptions = {},
803
990
  ): Promise<FlexToolCatalog<TScope>> {
804
- const mode = options.declarations ?? 'typescript';
805
- if (!describeFormats.has(mode)) {
806
- throw new FlexHarnessValidationError('toolCatalog.declarations must be "typescript" or "json-schema".');
807
- }
991
+ const normalized = normalizeToolCatalogOptions(options);
992
+ const mode = normalized.declarations;
808
993
  if (typeof catalog !== 'object' || catalog === null) {
809
994
  throw new FlexHarnessValidationError('A tool catalog must be a plain object.');
810
995
  }
@@ -818,7 +1003,13 @@ export class FlexToolCatalog<TScope = unknown> {
818
1003
  core = FlexToolCatalogCore.build(catalog, mode);
819
1004
  byMode.set(mode, core);
820
1005
  }
821
- return new FlexToolCatalog<TScope>(await core, undefined);
1006
+ return new FlexToolCatalog<TScope>(
1007
+ await core,
1008
+ undefined,
1009
+ normalized.codeMode,
1010
+ normalized.outputTypeDepth,
1011
+ normalized.describeTopMatch,
1012
+ );
822
1013
  }
823
1014
 
824
1015
  /** Every method path this catalog offers, sorted. */
@@ -837,6 +1028,9 @@ export class FlexToolCatalog<TScope = unknown> {
837
1028
  return new FlexToolCatalog<TScope>(
838
1029
  this.core,
839
1030
  new Set([...this.core.methods.keys()].filter((path) => allowed.has(path) && this.isAdmitted(path))),
1031
+ this.codeMode,
1032
+ this.outputTypeDepth,
1033
+ this.describeTopMatch,
840
1034
  );
841
1035
  }
842
1036
 
@@ -844,7 +1038,10 @@ export class FlexToolCatalog<TScope = unknown> {
844
1038
  return this.core.methods.has(path) && this.isAdmitted(path);
845
1039
  }
846
1040
 
847
- /** The three built-in tools as the model sees them. Their text depends only on the catalog. */
1041
+ /**
1042
+ * The built-in tools as the model sees them: the three catalog tools, and `run_code` in code
1043
+ * mode. Their text depends only on the catalog and its options.
1044
+ */
848
1045
  public toolDefinitions(): Record<string, IFlexCatalogToolDefinition> {
849
1046
  const typescript = this.core.mode === 'typescript';
850
1047
  const methodPath = { type: 'string', maxLength: limits.maxMethodPathCharacters } as const;
@@ -855,6 +1052,7 @@ export class FlexToolCatalog<TScope = unknown> {
855
1052
  'name, summary and description; `namespace` (a dotted path) narrows the search or, without `query`, lists',
856
1053
  `the namespace like ls. Read a method's ${typescript ? 'TypeScript declaration' : 'description and JSON Schema'}`,
857
1054
  'with describe_tools before calling it.',
1055
+ ...(this.describeTopMatch ? ['When one match clearly leads, its declaration follows the results.'] : []),
858
1056
  this.topLevelLine(),
859
1057
  ].join(' '),
860
1058
  inputSchema: {
@@ -899,11 +1097,50 @@ export class FlexToolCatalog<TScope = unknown> {
899
1097
  additionalProperties: false,
900
1098
  },
901
1099
  },
1100
+ ...(this.codeMode ? { [FLEX_CODE_MODE_TOOL_NAME]: this.codeToolDefinition(this.codeMode) } : {}),
902
1101
  };
903
1102
  }
904
1103
 
905
- /** Search results or a namespace listing as text. */
906
- public search(input: IFlexCatalogSearchInput = {}): string {
1104
+ private codeToolDefinition(codeMode: Readonly<IFlexCodeModeLimits>): IFlexCatalogToolDefinition {
1105
+ return {
1106
+ description: [
1107
+ 'Run a short TypeScript or JavaScript program against the catalog, for work that takes several calls:',
1108
+ 'loops, filters, joins. Call a method as `await api.<dotted path>(params)`, for example',
1109
+ '`await api.banking.listMovements({ accountId })`, with the declarations describe_tools returns; a refused',
1110
+ 'call throws an `ApiError` whose `details` say why. `return` a JSON value: it and the `console.log` output',
1111
+ 'come back, nothing else does. No network, files, timers or imports;',
1112
+ `at most ${codeMode.maxCalls} calls and ${Math.round(codeMode.wallMs / 1000)} seconds.`,
1113
+ ].join(' '),
1114
+ inputSchema: {
1115
+ type: 'object',
1116
+ properties: { code: { type: 'string', minLength: 1, maxLength: codeMode.maxSourceBytes } },
1117
+ required: ['code'],
1118
+ additionalProperties: false,
1119
+ },
1120
+ };
1121
+ }
1122
+
1123
+ /** Hands proposed calls to the catalog's `onProposals` and returns what the model sees for them. */
1124
+ public async propose(
1125
+ proposals: readonly IFlexCatalogProposal[],
1126
+ context: IFlexCatalogProposalContext<TScope>,
1127
+ ): Promise<unknown> {
1128
+ if (!this.core.onProposals) {
1129
+ throw new FlexHarnessValidationError(
1130
+ `Tool catalog method "${proposals[0]?.method}" proposed a call, but the catalog has no onProposals.`,
1131
+ );
1132
+ }
1133
+ return await this.core.onProposals(proposals, context as IFlexCatalogProposalContext);
1134
+ }
1135
+
1136
+ /**
1137
+ * Search results or a namespace listing as text. A clear top match of a query's first page is
1138
+ * followed by its declaration, as `describe` gives it with `options`.
1139
+ */
1140
+ public search(
1141
+ input: IFlexCatalogSearchInput = {},
1142
+ options: { declaredTypes?: Iterable<string>; maxBytes?: number } = {},
1143
+ ): string {
907
1144
  const limit = input.limit ?? limits.defaultSearchResults;
908
1145
  const offset = input.offset ?? 0;
909
1146
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > limits.maxSearchResults) {
@@ -929,7 +1166,16 @@ export class FlexToolCatalog<TScope = unknown> {
929
1166
  if (matches.length === 0) {
930
1167
  return [`No methods match "${query}"${namespace ? ` in ${namespace}` : ''}.`, this.topLevelLine()].join('\n');
931
1168
  }
932
- return this.page(matches.map((entry) => `${entry.path} - ${entry.summary}`), offset, limit, 'matches');
1169
+ const results = this.page(matches.map(({ entry }) => `${entry.path} - ${entry.summary}`), offset, limit, 'matches');
1170
+ const [top, next] = matches;
1171
+ const clear = offset === 0 && (!next || (top.named && top.score >= clearMatchMargin * next.score));
1172
+ if (!this.describeTopMatch || !clear) return results;
1173
+ // The declaration follows only while the whole answer fits what describe_tools may answer;
1174
+ // an answer over the output limit would lose the results too.
1175
+ const maxBytes = Math.min(options.maxBytes ?? limits.maxDescribeBytes, limits.maxDescribeBytes);
1176
+ const declaration = this.describe({ methods: [top.entry.path] }, { ...options, maxBytes }).output;
1177
+ const answer = `${results}\n\n${typeof declaration === 'string' ? declaration : JSON.stringify(declaration)}`;
1178
+ return Buffer.byteLength(JSON.stringify(answer), 'utf8') <= maxBytes ? answer : results;
933
1179
  }
934
1180
  const node = tree.get(namespace)!;
935
1181
  const lines = [...node.children.entries()].map(([child, count]) => `${child}.* (${count} method${count === 1 ? '' : 's'})`);
@@ -957,8 +1203,11 @@ export class FlexToolCatalog<TScope = unknown> {
957
1203
  const entries = requestedMethods.filter((path) => this.has(path)).sort(compareCodePoints)
958
1204
  .map((path) => this.core.methods.get(path)!);
959
1205
  const unknownMethods = requestedMethods.filter((path) => !this.has(path)).sort(compareCodePoints);
960
- const knownTypes = requestedTypes.filter((name) => this.core.types.has(name)).sort(compareCodePoints);
961
- const unknownTypes = requestedTypes.filter((name) => !this.core.types.has(name)).sort(compareCodePoints);
1206
+ // A type is asked for by its identifier or by the name its schema gives it.
1207
+ const identifierOf = (name: string): string | undefined =>
1208
+ this.core.types.has(name) ? name : this.core.typeIdentifiers.get(name);
1209
+ const knownTypes = [...new Set(requestedTypes.flatMap((name) => identifierOf(name) ?? []))].sort(compareCodePoints);
1210
+ const unknownTypes = requestedTypes.filter((name) => identifierOf(name) === undefined).sort(compareCodePoints);
962
1211
  const notes = [
963
1212
  ...unknownMethods.map((path) => {
964
1213
  const suggestions = closeMatches(path, this.methods);
@@ -977,18 +1226,18 @@ export class FlexToolCatalog<TScope = unknown> {
977
1226
  : this.describeTypescript(entries, knownTypes, notes, new Set(options.declaredTypes ?? []), maxBytes);
978
1227
  }
979
1228
 
980
- /** The named types `describe_tools` results in `messages` declared. */
1229
+ /** The named types the `describe_tools` and `search_tools` results in `messages` declared. */
981
1230
  public declaredTypesIn(messages: readonly plugins.ModelMessage[] | undefined): Set<string> {
982
1231
  const declared = new Set<string>();
983
1232
  for (const message of messages ?? []) {
984
1233
  if (message.role !== 'tool' || !Array.isArray(message.content)) continue;
985
1234
  for (const part of message.content) {
986
- if (part.type !== 'tool-result' || part.toolName !== toolNames.describe) continue;
1235
+ if (part.type !== 'tool-result' || (part.toolName !== toolNames.describe && part.toolName !== toolNames.search)) continue;
987
1236
  const output = part.output as { type?: string; value?: unknown };
988
- if (output.type !== 'text' || typeof output.value !== 'string' || !output.value.startsWith(declaredTypesHeader)) continue;
989
- const lineEnd = output.value.indexOf('\n');
990
- const header = output.value.slice(declaredTypesHeader.length, lineEnd === -1 ? undefined : lineEnd);
991
- for (const name of header.split(', ')) if (this.core.types.has(name)) declared.add(name);
1237
+ if (output.type !== 'text' || typeof output.value !== 'string') continue;
1238
+ for (const [, header] of output.value.matchAll(declaredTypesLine)) {
1239
+ for (const name of header.split(', ')) if (this.core.types.has(name)) declared.add(name);
1240
+ }
992
1241
  }
993
1242
  }
994
1243
  return declared;
@@ -1041,6 +1290,19 @@ export class FlexToolCatalog<TScope = unknown> {
1041
1290
  success: true,
1042
1291
  method: path,
1043
1292
  params: validated,
1293
+ decide: async (context) => {
1294
+ const decision = entry.onCall ? await entry.onCall(validated, context) : 'run';
1295
+ if (decision === 'run' || decision === 'propose') return decision;
1296
+ if (
1297
+ isSchemaRecord(decision)
1298
+ && Object.keys(decision).length === 1
1299
+ && typeof decision.deny === 'string'
1300
+ && decision.deny.trim()
1301
+ ) {
1302
+ return { deny: decision.deny.slice(0, 2000) };
1303
+ }
1304
+ throw new FlexHarnessValidationError(`Tool catalog method "${path}" onCall returned an invalid decision.`);
1305
+ },
1044
1306
  run: (context) => entry.run(validated, context),
1045
1307
  };
1046
1308
  }
@@ -1118,10 +1380,24 @@ export class FlexToolCatalog<TScope = unknown> {
1118
1380
  const included: IMethodEntry[] = [];
1119
1381
  const omitted: string[] = [];
1120
1382
  const alreadyDeclared = new Set([...declared].filter((name) => !requestedTypes.includes(name)));
1121
- let types = this.core.typeClosure(requestedTypes, alreadyDeclared);
1383
+ // Parameters' types are declared in full; a result's only `outputTypeDepth` levels deep.
1384
+ const typesFor = (methods: IMethodEntry[]): string[] => {
1385
+ const inputs = [...requestedTypes, ...methods.flatMap((entry) => this.core.methodTypes(entry).input)];
1386
+ const outputs = methods.flatMap((entry) => this.core.methodTypes(entry).output);
1387
+ return [...new Set([
1388
+ ...this.core.typeClosure(inputs, alreadyDeclared),
1389
+ ...this.core.typeLevels(outputs, this.outputTypeDepth, alreadyDeclared),
1390
+ ])].sort(compareCodePoints);
1391
+ };
1392
+ let types = typesFor([]);
1122
1393
  const assemble = (methods: IMethodEntry[], typeNames: string[], skipped: string[]): string => {
1123
- const used = new Set(methods.flatMap((entry) => this.core.typeClosure(this.core.renderMethod(entry).types)));
1124
- const earlier = [...used].filter((name) => declared.has(name) && !typeNames.includes(name)).sort(compareCodePoints);
1394
+ const shown = new Set(typeNames);
1395
+ const named = new Set([
1396
+ ...methods.flatMap((entry) => this.core.renderMethod(entry).types),
1397
+ ...typeNames.flatMap((name) => this.core.renderType(name).types),
1398
+ ]);
1399
+ const earlier = [...named].filter((name) => declared.has(name) && !shown.has(name)).sort(compareCodePoints);
1400
+ const undeclared = [...named].filter((name) => !declared.has(name) && !shown.has(name)).sort(compareCodePoints);
1125
1401
  const blocks: string[] = [];
1126
1402
  if (typeNames.length) blocks.push(`${declaredTypesHeader}${typeNames.join(', ')}`);
1127
1403
  for (const name of typeNames) blocks.push(this.core.renderType(name).text);
@@ -1134,6 +1410,7 @@ export class FlexToolCatalog<TScope = unknown> {
1134
1410
  }
1135
1411
  const trailing = [
1136
1412
  ...(earlier.length ? [`Declared earlier: ${earlier.join(', ')}; describe_tools({"types":[...]}) declares them again.`] : []),
1413
+ ...(undeclared.length ? [`Not declared yet: ${undeclared.join(', ')}; describe_tools({"types":[...]}) declares them.`] : []),
1137
1414
  ...notes,
1138
1415
  ...(skipped.length ? [`Not included to stay within the size limit: ${skipped.join(', ')}; describe them in another call.`] : []),
1139
1416
  ];
@@ -1142,10 +1419,7 @@ export class FlexToolCatalog<TScope = unknown> {
1142
1419
  };
1143
1420
  for (const entry of entries) {
1144
1421
  const candidate = [...included, entry];
1145
- const candidateTypes = this.core.typeClosure(
1146
- [...requestedTypes, ...candidate.flatMap((method) => this.core.renderMethod(method).types)],
1147
- alreadyDeclared,
1148
- );
1422
+ const candidateTypes = typesFor(candidate);
1149
1423
  const text = assemble(candidate, candidateTypes, entries.slice(candidate.length).map((method) => method.path));
1150
1424
  if (Buffer.byteLength(JSON.stringify(text), 'utf8') > maxBytes && included.length > 0) {
1151
1425
  omitted.push(...entries.slice(included.length).map((method) => method.path));
@@ -1170,7 +1444,7 @@ export class FlexToolCatalog<TScope = unknown> {
1170
1444
  const type = this.core.types.get(name)!;
1171
1445
  const schema = type.definition ?? type.schema?.json;
1172
1446
  return [{
1173
- name,
1447
+ name: type.schemaName ?? name,
1174
1448
  ...(type.description ? { description: type.description } : {}),
1175
1449
  ...(schema === undefined ? { declaration: type.declaration! } : { schema: withoutDraft(schema) }),
1176
1450
  }];
@@ -1213,7 +1487,8 @@ export class FlexToolCatalog<TScope = unknown> {
1213
1487
  namedType: (ref) => {
1214
1488
  const match = definitionRefPattern.exec(ref);
1215
1489
  const key = match ? decodePointerToken(match[2]) : undefined;
1216
- return key && typeNamePattern.test(key) ? key : undefined;
1490
+ if (key === undefined) return undefined;
1491
+ return this.core.typeIdentifiers.get(key) ?? (typeNamePattern.test(key) ? key : undefined);
1217
1492
  },
1218
1493
  resolve: (ref) => {
1219
1494
  const target = resolvePointer(root, ref);
@@ -1383,14 +1658,17 @@ const constraintText = (subset: Record<string, unknown>): string => {
1383
1658
  };
1384
1659
 
1385
1660
  /**
1386
- * While a run has a catalog, its three tool names are reserved, and a method path must differ from
1387
- * every tool name.
1661
+ * While a run has a catalog, its tool names are reserved (`run_code` in code mode too), and a
1662
+ * method path must differ from every tool name.
1388
1663
  */
1389
1664
  export const assertNoCatalogCollisions = (
1390
1665
  catalog: FlexToolCatalog<unknown>,
1391
1666
  tools: Readonly<Record<string, unknown>>,
1392
1667
  ): void => {
1393
- const catalogToolNames = new Set<string>(Object.values(toolNames));
1668
+ const catalogToolNames = new Set<string>([
1669
+ ...Object.values(toolNames),
1670
+ ...(catalog.codeMode ? [FLEX_CODE_MODE_TOOL_NAME] : []),
1671
+ ]);
1394
1672
  for (const name of catalogToolNames) {
1395
1673
  if (Object.prototype.hasOwnProperty.call(tools, name)) {
1396
1674
  throw new FlexHarnessValidationError(`Tool provider cannot define enabled built-in tool "${name}".`);