@modelprofile.com/flexharness 10.14.0 → 10.15.1

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.
@@ -57,6 +57,7 @@ interface IMethodEntry {
57
57
  readonly summary: string;
58
58
  readonly description?: string;
59
59
  readonly tags: readonly string[];
60
+ readonly aliases: readonly string[];
60
61
  readonly examples: readonly string[];
61
62
  readonly input: ICatalogSchema;
62
63
  readonly inputScope: ISchemaScope;
@@ -68,7 +69,10 @@ interface IMethodEntry {
68
69
  }
69
70
 
70
71
  interface ITypeEntry {
72
+ /** The identifier the type is declared under. */
71
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;
72
76
  readonly description?: string;
73
77
  readonly declaration?: string;
74
78
  /** A catalog type's schema. */
@@ -107,17 +111,28 @@ const reservedWords = new Set([
107
111
  const controlPattern = /[\u0000-\u001f\u007f]/;
108
112
  const definitionRefPattern = /^#\/(\$defs|definitions)\/([^/]+)$/;
109
113
  const declaredTypesHeader = '// Declares types: ';
114
+ const declaredTypesLine = /^\/\/ Declares types: (.*)$/gm;
110
115
  const identifierScanPattern = /[A-Za-z_$][A-Za-z0-9_$]*/g;
111
116
  const methodKeys = new Set([
112
- 'method', 'summary', 'description', 'tags', 'examples', 'inputSchema', 'outputSchema', 'declaration', 'run', 'onCall',
117
+ 'method', 'summary', 'description', 'tags', 'aliases', 'examples', 'inputSchema', 'outputSchema', 'declaration', 'run', 'onCall',
113
118
  ]);
114
- const catalogKeys = new Set(['methods', 'types', 'onProposals']);
119
+ const catalogKeys = new Set(['methods', 'types', 'vocabulary', 'onProposals']);
115
120
  const typeKeys = new Set(['name', 'description', 'schema', 'declaration']);
116
121
  const stopWords = new Set([
117
- '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',
118
123
  'of', 'on', 'or', 'that', 'the', 'this', 'to', 'with',
119
124
  ]);
120
- 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;
121
136
  type TSearchField = keyof typeof fieldWeights;
122
137
  const bm25K1 = 1.2;
123
138
  const bm25B = 0.75;
@@ -216,6 +231,13 @@ const searchTerms = (text: string): string[] => text
216
231
  .filter(Boolean)
217
232
  .map(stem);
218
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
+
219
241
  const editDistance = (left: string, right: string, limit: number): number => {
220
242
  if (Math.abs(left.length - right.length) > limit) return limit + 1;
221
243
  let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
@@ -301,6 +323,25 @@ const textOf = (
301
323
  return value;
302
324
  };
303
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
+
304
345
  const textListOf = (
305
346
  value: unknown,
306
347
  label: string,
@@ -314,6 +355,30 @@ const textListOf = (
314
355
  return value.map((entry, index) => textOf(entry, `${label}[${index}]`, options));
315
356
  };
316
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
+
317
382
  async function resolveCatalogSchema(schema: unknown, label: string): Promise<ICatalogSchema> {
318
383
  let json: unknown;
319
384
  let validate: TValidate | undefined;
@@ -355,10 +420,14 @@ async function resolveCatalogSchema(schema: unknown, label: string): Promise<ICa
355
420
  class CatalogSearchIndex {
356
421
  private readonly postings = new Map<string, Array<{ index: number; weight: number }>>();
357
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[]>>;
358
425
 
359
- 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))]));
360
428
  const fieldTexts = (entry: IMethodEntry): Record<TSearchField, string[]> => ({
361
429
  name: searchTerms(entry.name),
430
+ aliases: entry.aliases.flatMap(searchTerms),
362
431
  namespace: searchTerms(entry.namespace),
363
432
  summary: searchTerms(entry.summary),
364
433
  tags: entry.tags.flatMap(searchTerms),
@@ -393,11 +462,21 @@ class CatalogSearchIndex {
393
462
  }
394
463
  }
395
464
 
396
- /** Methods ranked by BM25 over name, namespace, summary, tags and description. */
397
- public search(query: string, accept: (entry: IMethodEntry) => boolean): IMethodEntry[] {
398
- 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
+ }
399
478
  const meaningful = allTerms.filter((term) => !stopWords.has(term));
400
- const terms = meaningful.length > 0 ? meaningful : allTerms;
479
+ const terms = [...new Set(meaningful.length > 0 ? meaningful : allTerms)];
401
480
  const scores = new Map<number, number>();
402
481
  for (const term of terms) {
403
482
  const idf = this.idf.get(term);
@@ -407,20 +486,23 @@ class CatalogSearchIndex {
407
486
  }
408
487
  }
409
488
  const exact = query.trim().toLowerCase();
489
+ const queryTerms = new Set(allTerms);
410
490
  return [...scores.entries()]
411
491
  .map(([index, score]) => {
412
492
  const entry = this.entries[index];
413
- const bonus = entry.path.toLowerCase() === exact || entry.name.toLowerCase() === exact ? 1000 : 0;
414
- 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 };
415
496
  })
416
497
  .filter(({ entry }) => accept(entry))
417
- .sort((left, right) => right.score - left.score || compareCodePoints(left.entry.path, right.entry.path))
418
- .map(({ entry }) => entry);
498
+ .sort((left, right) => right.score - left.score || compareCodePoints(left.entry.path, right.entry.path));
419
499
  }
420
500
  }
421
501
 
422
502
  class FlexToolCatalogCore {
423
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[] }>();
424
506
  public readonly renderedTypes = new Map<string, IRenderedDeclaration>();
425
507
  public readonly validators = new Map<string, plugins.JsonSchemaValidator>();
426
508
  public readonly selfContainedInputs = new Map<string, TJsonSchemaNode>();
@@ -430,9 +512,12 @@ class FlexToolCatalogCore {
430
512
  public readonly mode: TDeclarationMode,
431
513
  public readonly methods: ReadonlyMap<string, IMethodEntry>,
432
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[],
433
518
  public readonly onProposals: IFlexToolCatalog['onProposals'] | undefined,
434
519
  ) {
435
- this.index = new CatalogSearchIndex([...methods.values()]);
520
+ this.index = new CatalogSearchIndex([...methods.values()], vocabulary);
436
521
  }
437
522
 
438
523
  public static async build(catalog: unknown, mode: TDeclarationMode): Promise<FlexToolCatalogCore> {
@@ -443,19 +528,47 @@ class FlexToolCatalogCore {
443
528
  if (onProposals !== undefined && typeof onProposals !== 'function') {
444
529
  throw new FlexHarnessValidationError('A tool catalog onProposals must be a function.');
445
530
  }
531
+ const vocabulary = vocabularyOf(catalog.vocabulary);
446
532
  if (!Array.isArray(catalog.methods) || catalog.methods.length > limits.maxMethods) {
447
533
  throw new FlexHarnessValidationError(`Tool catalog methods must be an array with at most ${limits.maxMethods} entries.`);
448
534
  }
449
535
  if (catalog.types !== undefined && (!Array.isArray(catalog.types) || catalog.types.length > limits.maxTypes)) {
450
536
  throw new FlexHarnessValidationError(`Tool catalog types must be an array with at most ${limits.maxTypes} entries.`);
451
537
  }
452
- const catalogTypes = new Map<string, ITypeEntry>();
538
+ const namedTypes = new Map<string, ITypeEntry>();
453
539
  for (const [index, type] of ((catalog.types ?? []) as unknown[]).entries()) {
454
540
  const entry = await FlexToolCatalogCore.normalizeType(type, `Tool catalog type ${index}`);
455
- if (catalogTypes.has(entry.name)) {
541
+ if (namedTypes.has(entry.name)) {
456
542
  throw new FlexHarnessValidationError(`Tool catalog type "${entry.name}" is defined twice.`);
457
543
  }
458
- 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 }) });
459
572
  }
460
573
  const methodInputs: Array<Omit<IMethodEntry, 'inputScope' | 'outputScope'>> = [];
461
574
  for (const [index, method] of (catalog.methods as unknown[]).entries()) {
@@ -476,7 +589,7 @@ class FlexToolCatalogCore {
476
589
  }
477
590
  }
478
591
 
479
- const registry = new Map<string, ITypeEntry>(catalogTypes);
592
+ const registry = new Map<string, ITypeEntry>([...catalogTypes.values()].map((type) => [type.name, type]));
480
593
  const canonicalDefinitions = new Map<string, string>();
481
594
  const scopeOf = (root: TJsonSchemaNode, label: string, requireSchemas: boolean): ISchemaScope => {
482
595
  const scope: ISchemaScope = { root, localNames: new Map() };
@@ -507,7 +620,10 @@ class FlexToolCatalogCore {
507
620
  };
508
621
  for (const type of catalogTypes.values()) {
509
622
  if (!type.schema) continue;
510
- 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
+ });
511
627
  }
512
628
  const methods = new Map<string, IMethodEntry>();
513
629
  for (const method of methodInputs) {
@@ -524,6 +640,8 @@ class FlexToolCatalogCore {
524
640
  mode,
525
641
  methods,
526
642
  types,
643
+ typeIdentifiers,
644
+ vocabulary,
527
645
  onProposals === undefined
528
646
  ? undefined
529
647
  : (proposals, context) => (onProposals as NonNullable<IFlexToolCatalog['onProposals']>).call(catalog, proposals, context),
@@ -535,21 +653,16 @@ class FlexToolCatalogCore {
535
653
  const unsupported = Object.keys(value).find((key) => !typeKeys.has(key));
536
654
  if (unsupported) throw new FlexHarnessValidationError(`${label} does not support "${unsupported}".`);
537
655
  const name = textOf(value.name, `${label} name`, { maxCharacters: limits.maxIdentifierCharacters, singleLine: true });
538
- if (!typeNamePattern.test(name) || reservedWords.has(name)) {
539
- throw new FlexHarnessValidationError(`${label} name must be an identifier that is not a reserved word.`);
540
- }
656
+ if (/\s/.test(name)) throw new FlexHarnessValidationError(`${label} name must not contain whitespace.`);
541
657
  if (value.schema === undefined && value.declaration === undefined) {
542
658
  throw new FlexHarnessValidationError(`Tool catalog type "${name}" needs a schema or a declaration.`);
543
659
  }
544
- const description = value.description === undefined
545
- ? undefined
546
- : 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();
547
663
  const declaration = value.declaration === undefined
548
664
  ? undefined
549
665
  : textOf(value.declaration, `Tool catalog type "${name}" declaration`, { maxBytes: limits.maxDeclarationBytes });
550
- if (declaration !== undefined && !new RegExp(`\\b(?:interface|type|enum|class)\\s+${name}\\b`).test(declaration)) {
551
- throw new FlexHarnessValidationError(`Tool catalog type "${name}" declaration must declare "${name}".`);
552
- }
553
666
  const schema = value.schema === undefined
554
667
  ? undefined
555
668
  : await resolveCatalogSchema(value.schema, `Tool catalog type "${name}" schema`);
@@ -594,12 +707,16 @@ class FlexToolCatalogCore {
594
707
  const summary = textOf(value.summary, `${methodLabel} summary`, {
595
708
  maxCharacters: limits.maxSummaryCharacters, singleLine: true,
596
709
  }).trim();
597
- const description = value.description === undefined
598
- ? undefined
599
- : textOf(value.description, `${methodLabel} description`, { maxBytes: limits.maxDescriptionBytes }).trim();
710
+ const description = optionalTextOf(value.description, `${methodLabel} description`, {
711
+ maxBytes: limits.maxDescriptionBytes,
712
+ })?.trim();
600
713
  const tags = textListOf(value.tags, `${methodLabel} tags`, limits.maxTags, {
601
714
  maxCharacters: limits.maxTagCharacters, singleLine: true,
602
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}]`));
603
720
  const examples = textListOf(value.examples, `${methodLabel} examples`, limits.maxExamples, {
604
721
  maxBytes: limits.maxExampleBytes,
605
722
  });
@@ -636,6 +753,7 @@ class FlexToolCatalogCore {
636
753
  summary,
637
754
  ...(description ? { description } : {}),
638
755
  tags: Object.freeze(tags),
756
+ aliases: Object.freeze(aliases),
639
757
  examples: Object.freeze(examples),
640
758
  input,
641
759
  ...(output ? { output } : {}),
@@ -688,7 +806,7 @@ class FlexToolCatalogCore {
688
806
  const local = scope.localNames.get(`${match[1]}/${key}`);
689
807
  if (local) return local;
690
808
  if (resolvePointer(scope.root, ref) !== undefined) return undefined;
691
- return this.types.has(key) ? key : undefined;
809
+ return this.typeIdentifiers.get(key);
692
810
  },
693
811
  resolve: (ref: string): TJsonSchemaNode | undefined => {
694
812
  const target = resolvePointer(scope.root, ref);
@@ -702,12 +820,17 @@ class FlexToolCatalogCore {
702
820
  if (cached) return cached;
703
821
  const type = this.types.get(name)!;
704
822
  let rendered: IRenderedDeclaration;
823
+ const schemaNote = type.schemaName === undefined ? undefined : `Named "${type.schemaName}" in the schema.`;
705
824
  if (type.declaration !== undefined) {
706
- 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
+ };
707
829
  } else {
708
830
  const schema = type.definition ?? type.schema!.json;
709
831
  const writer = new SchemaDeclarationWriter(this.scopeContext(type.scope!));
710
- 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);
711
834
  rendered = { text, types: writer.usedTypes.filter((used) => used !== name) };
712
835
  }
713
836
  this.renderedTypes.set(name, rendered);
@@ -720,6 +843,9 @@ class FlexToolCatalogCore {
720
843
  let rendered: IRenderedDeclaration;
721
844
  if (entry.declaration !== undefined) {
722
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: [] });
723
849
  } else {
724
850
  const inputWriter = new SchemaDeclarationWriter(this.scopeContext(entry.inputScope));
725
851
  const members = inputWriter.objectMembers(entry.input.json);
@@ -744,6 +870,10 @@ class FlexToolCatalogCore {
744
870
  text: `${docBlock(lines.join('\n'), '')}${signature}`,
745
871
  types: [...new Set([...inputWriter.usedTypes, ...(outputWriter?.usedTypes ?? [])])].sort(compareCodePoints),
746
872
  };
873
+ this.methodTypeSets.set(entry.path, {
874
+ input: [...new Set(inputWriter.usedTypes)].sort(compareCodePoints),
875
+ output: [...new Set(outputWriter?.usedTypes ?? [])].sort(compareCodePoints),
876
+ });
747
877
  }
748
878
  this.renderedMethods.set(entry.path, rendered);
749
879
  return rendered;
@@ -757,6 +887,28 @@ class FlexToolCatalogCore {
757
887
  return [...found].sort(compareCodePoints);
758
888
  }
759
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
+
760
912
  /** `names` and every type they use, transitively, sorted. */
761
913
  public typeClosure(names: Iterable<string>, stop: ReadonlySet<string> = new Set()): string[] {
762
914
  const result = new Set<string>();
@@ -783,7 +935,8 @@ class FlexToolCatalogCore {
783
935
  const ref = queue.pop()!;
784
936
  const match = definitionRefPattern.exec(ref);
785
937
  if (!match || resolvePointer(root, ref) !== undefined) continue;
786
- 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);
787
940
  if (!type?.schema) continue;
788
941
  const container = (isSchemaRecord(root[match[1]]) ? root[match[1]] : (root[match[1]] = {})) as Record<string, unknown>;
789
942
  container[decodePointerToken(match[2])] = withoutDraft(structuredClone(type.schema.json) as TJsonSchemaNode);
@@ -821,6 +974,10 @@ export class FlexToolCatalog<TScope = unknown> {
821
974
  private readonly admitted: ReadonlySet<string> | undefined,
822
975
  /** The limits of `run_code`; absent while code mode is off. */
823
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,
824
981
  ) {}
825
982
 
826
983
  /**
@@ -846,7 +1003,13 @@ export class FlexToolCatalog<TScope = unknown> {
846
1003
  core = FlexToolCatalogCore.build(catalog, mode);
847
1004
  byMode.set(mode, core);
848
1005
  }
849
- return new FlexToolCatalog<TScope>(await core, undefined, normalized.codeMode);
1006
+ return new FlexToolCatalog<TScope>(
1007
+ await core,
1008
+ undefined,
1009
+ normalized.codeMode,
1010
+ normalized.outputTypeDepth,
1011
+ normalized.describeTopMatch,
1012
+ );
850
1013
  }
851
1014
 
852
1015
  /** Every method path this catalog offers, sorted. */
@@ -866,6 +1029,8 @@ export class FlexToolCatalog<TScope = unknown> {
866
1029
  this.core,
867
1030
  new Set([...this.core.methods.keys()].filter((path) => allowed.has(path) && this.isAdmitted(path))),
868
1031
  this.codeMode,
1032
+ this.outputTypeDepth,
1033
+ this.describeTopMatch,
869
1034
  );
870
1035
  }
871
1036
 
@@ -887,6 +1052,7 @@ export class FlexToolCatalog<TScope = unknown> {
887
1052
  'name, summary and description; `namespace` (a dotted path) narrows the search or, without `query`, lists',
888
1053
  `the namespace like ls. Read a method's ${typescript ? 'TypeScript declaration' : 'description and JSON Schema'}`,
889
1054
  'with describe_tools before calling it.',
1055
+ ...(this.describeTopMatch ? ['When one match clearly leads, its declaration follows the results.'] : []),
890
1056
  this.topLevelLine(),
891
1057
  ].join(' '),
892
1058
  inputSchema: {
@@ -967,8 +1133,14 @@ export class FlexToolCatalog<TScope = unknown> {
967
1133
  return await this.core.onProposals(proposals, context as IFlexCatalogProposalContext);
968
1134
  }
969
1135
 
970
- /** Search results or a namespace listing as text. */
971
- public search(input: IFlexCatalogSearchInput = {}): string {
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 {
972
1144
  const limit = input.limit ?? limits.defaultSearchResults;
973
1145
  const offset = input.offset ?? 0;
974
1146
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > limits.maxSearchResults) {
@@ -994,7 +1166,16 @@ export class FlexToolCatalog<TScope = unknown> {
994
1166
  if (matches.length === 0) {
995
1167
  return [`No methods match "${query}"${namespace ? ` in ${namespace}` : ''}.`, this.topLevelLine()].join('\n');
996
1168
  }
997
- 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;
998
1179
  }
999
1180
  const node = tree.get(namespace)!;
1000
1181
  const lines = [...node.children.entries()].map(([child, count]) => `${child}.* (${count} method${count === 1 ? '' : 's'})`);
@@ -1022,8 +1203,11 @@ export class FlexToolCatalog<TScope = unknown> {
1022
1203
  const entries = requestedMethods.filter((path) => this.has(path)).sort(compareCodePoints)
1023
1204
  .map((path) => this.core.methods.get(path)!);
1024
1205
  const unknownMethods = requestedMethods.filter((path) => !this.has(path)).sort(compareCodePoints);
1025
- const knownTypes = requestedTypes.filter((name) => this.core.types.has(name)).sort(compareCodePoints);
1026
- 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);
1027
1211
  const notes = [
1028
1212
  ...unknownMethods.map((path) => {
1029
1213
  const suggestions = closeMatches(path, this.methods);
@@ -1042,18 +1226,18 @@ export class FlexToolCatalog<TScope = unknown> {
1042
1226
  : this.describeTypescript(entries, knownTypes, notes, new Set(options.declaredTypes ?? []), maxBytes);
1043
1227
  }
1044
1228
 
1045
- /** The named types `describe_tools` results in `messages` declared. */
1229
+ /** The named types the `describe_tools` and `search_tools` results in `messages` declared. */
1046
1230
  public declaredTypesIn(messages: readonly plugins.ModelMessage[] | undefined): Set<string> {
1047
1231
  const declared = new Set<string>();
1048
1232
  for (const message of messages ?? []) {
1049
1233
  if (message.role !== 'tool' || !Array.isArray(message.content)) continue;
1050
1234
  for (const part of message.content) {
1051
- 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;
1052
1236
  const output = part.output as { type?: string; value?: unknown };
1053
- if (output.type !== 'text' || typeof output.value !== 'string' || !output.value.startsWith(declaredTypesHeader)) continue;
1054
- const lineEnd = output.value.indexOf('\n');
1055
- const header = output.value.slice(declaredTypesHeader.length, lineEnd === -1 ? undefined : lineEnd);
1056
- 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
+ }
1057
1241
  }
1058
1242
  }
1059
1243
  return declared;
@@ -1196,10 +1380,24 @@ export class FlexToolCatalog<TScope = unknown> {
1196
1380
  const included: IMethodEntry[] = [];
1197
1381
  const omitted: string[] = [];
1198
1382
  const alreadyDeclared = new Set([...declared].filter((name) => !requestedTypes.includes(name)));
1199
- 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([]);
1200
1393
  const assemble = (methods: IMethodEntry[], typeNames: string[], skipped: string[]): string => {
1201
- const used = new Set(methods.flatMap((entry) => this.core.typeClosure(this.core.renderMethod(entry).types)));
1202
- 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);
1203
1401
  const blocks: string[] = [];
1204
1402
  if (typeNames.length) blocks.push(`${declaredTypesHeader}${typeNames.join(', ')}`);
1205
1403
  for (const name of typeNames) blocks.push(this.core.renderType(name).text);
@@ -1212,6 +1410,7 @@ export class FlexToolCatalog<TScope = unknown> {
1212
1410
  }
1213
1411
  const trailing = [
1214
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.`] : []),
1215
1414
  ...notes,
1216
1415
  ...(skipped.length ? [`Not included to stay within the size limit: ${skipped.join(', ')}; describe them in another call.`] : []),
1217
1416
  ];
@@ -1220,10 +1419,7 @@ export class FlexToolCatalog<TScope = unknown> {
1220
1419
  };
1221
1420
  for (const entry of entries) {
1222
1421
  const candidate = [...included, entry];
1223
- const candidateTypes = this.core.typeClosure(
1224
- [...requestedTypes, ...candidate.flatMap((method) => this.core.renderMethod(method).types)],
1225
- alreadyDeclared,
1226
- );
1422
+ const candidateTypes = typesFor(candidate);
1227
1423
  const text = assemble(candidate, candidateTypes, entries.slice(candidate.length).map((method) => method.path));
1228
1424
  if (Buffer.byteLength(JSON.stringify(text), 'utf8') > maxBytes && included.length > 0) {
1229
1425
  omitted.push(...entries.slice(included.length).map((method) => method.path));
@@ -1248,7 +1444,7 @@ export class FlexToolCatalog<TScope = unknown> {
1248
1444
  const type = this.core.types.get(name)!;
1249
1445
  const schema = type.definition ?? type.schema?.json;
1250
1446
  return [{
1251
- name,
1447
+ name: type.schemaName ?? name,
1252
1448
  ...(type.description ? { description: type.description } : {}),
1253
1449
  ...(schema === undefined ? { declaration: type.declaration! } : { schema: withoutDraft(schema) }),
1254
1450
  }];
@@ -1291,7 +1487,8 @@ export class FlexToolCatalog<TScope = unknown> {
1291
1487
  namedType: (ref) => {
1292
1488
  const match = definitionRefPattern.exec(ref);
1293
1489
  const key = match ? decodePointerToken(match[2]) : undefined;
1294
- 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);
1295
1492
  },
1296
1493
  resolve: (ref) => {
1297
1494
  const target = resolvePointer(root, ref);