@cerebro-labs/agentnava-sdk 0.44.1 → 0.47.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.
package/import-tools.js CHANGED
@@ -426,113 +426,325 @@ export function fromPostman(collection, options = {}) {
426
426
  return draft.finish(options);
427
427
  }
428
428
  const OPENAPI_METHODS = ['get', 'post', 'put', 'delete', 'patch'];
429
- function dereference(value, doc, seen = new Set(), validateReference) {
430
- if (!value.$ref)
431
- return value;
432
- validateReference?.(value);
433
- if (!value.$ref.startsWith('#/') || seen.has(value.$ref))
429
+ /** The object a local `#/...` reference names, one hop. */
430
+ function refTarget(ref, doc) {
431
+ if (!ref.startsWith('#/'))
434
432
  throw Error('Only acyclic local OpenAPI references are supported');
435
- seen.add(value.$ref);
436
433
  let target = doc;
437
- for (const key of value.$ref.slice(2).split('/').map(x => x.replace(/~1/g, '/').replace(/~0/g, '~'))) {
434
+ for (const key of ref.slice(2).split('/').map(x => x.replace(/~1/g, '/').replace(/~0/g, '~'))) {
438
435
  if (!target || typeof target !== 'object' || !Object.hasOwn(target, key))
439
- throw Error(`Unresolved OpenAPI reference ${value.$ref}`);
436
+ throw Error(`Unresolved OpenAPI reference ${ref}`);
440
437
  target = target[key];
441
438
  }
442
439
  if (!target || typeof target !== 'object' || Array.isArray(target))
443
440
  throw Error('OpenAPI reference must resolve to an object');
444
- return dereference(target, doc, seen, validateReference);
441
+ return target;
442
+ }
443
+ function dereference(value, doc, seen = new Set()) {
444
+ if (!value.$ref)
445
+ return value;
446
+ if (seen.has(value.$ref))
447
+ throw Error('Only acyclic local OpenAPI references are supported');
448
+ seen.add(value.$ref);
449
+ return dereference(refTarget(value.$ref, doc), doc, seen);
450
+ }
451
+ /** Keywords that only narrow which values are valid: a bound, a length, a
452
+ * pattern, a format. A tool parameter has no field for them, so they go into
453
+ * its description for the model, and the API enforces them, as it does for
454
+ * every other caller.
455
+ *
456
+ * THEY USED TO SKIP THE WHOLE OPERATION, even on an optional parameter, on the
457
+ * theory that a tool which cannot enforce a rule must not exist. Real documents
458
+ * put these on almost every field: a generated document with 188 operations
459
+ * drafted 77 tools. The rule was never the tool's to enforce; the API already
460
+ * does, and its 400 tells the model what to fix. */
461
+ const RULE_KEYWORDS = [
462
+ 'minimum', 'exclusiveMinimum', 'maximum', 'exclusiveMaximum', 'multipleOf', 'minLength', 'maxLength',
463
+ 'pattern', 'format', 'minItems', 'maxItems', 'uniqueItems', 'minProperties', 'maxProperties',
464
+ ];
465
+ const PRIMITIVE_TYPES = new Set(['string', 'number', 'integer', 'boolean']);
466
+ const PARAM_TYPES = new Set([...PRIMITIVE_TYPES, 'object', 'array']);
467
+ const TYPE_PHRASE = {
468
+ string: 'a string', number: 'a number', integer: 'an integer', boolean: 'true or false', object: 'an object', array: 'a list',
469
+ };
470
+ const refName = (ref) => ref.slice(ref.lastIndexOf('/') + 1);
471
+ /** Sentences joined into one description. A lone part gains no full stop. */
472
+ function sentences(...parts) {
473
+ const kept = parts.map(p => p?.trim()).filter((p) => !!p);
474
+ if (kept.length <= 1)
475
+ return kept[0];
476
+ return kept.map(p => /[.!?]$/.test(p) ? p : `${p}.`).join(' ');
477
+ }
478
+ /** The type a resolved schema declares, or the one its other keywords imply. */
479
+ function typeOf(s) {
480
+ if (typeof s.type === 'string')
481
+ return s.type;
482
+ if (s.properties !== undefined || s.additionalProperties !== undefined)
483
+ return 'object';
484
+ if (s.items !== undefined)
485
+ return 'array';
486
+ const values = s.enum?.filter(v => v !== null) ?? [];
487
+ if (values.length && values.every(v => typeof v === 'string'))
488
+ return 'string';
489
+ if (values.length && values.every(v => typeof v === 'boolean'))
490
+ return 'boolean';
491
+ if (values.length && values.every(v => typeof v === 'number'))
492
+ return values.every(Number.isInteger) ? 'integer' : 'number';
493
+ return undefined;
445
494
  }
446
- const OPENAPI_SCHEMA_FIELDS = new Set(['type', 'properties', 'items', 'required', 'enum', 'nullable', 'description', 'additionalProperties', '$ref', 'title', 'format', 'example', 'examples', 'default', 'readOnly', 'writeOnly', 'deprecated']);
447
- /** Schema Object siblings apply alongside the referenced schema in OpenAPI
448
- * 3.1. Reject them at every reference hop until that intersection is supported.
449
- * Parameter and request-body Reference Objects keep their separate semantics. */
450
- function checkSchemaReference(schema) {
451
- const sibling = Object.keys(schema).find(key => key !== '$ref');
452
- if (sibling !== undefined)
453
- throw Error(`Schema keyword ${sibling} alongside $ref is not supported`);
495
+ /** The rules a schema carries, in words: "at least 1", "3 to 64 characters". */
496
+ function ruleText(s, type) {
497
+ const n = (key) => typeof s[key] === 'number' && Number.isFinite(s[key]) ? s[key] : undefined;
498
+ const span = (min, max, unit) => {
499
+ const of = (x) => `${x} ${unit}${x === 1 ? '' : 's'}`;
500
+ if (min !== undefined && max !== undefined)
501
+ return min === max ? `exactly ${of(min)}` : `${min} to ${of(max)}`;
502
+ if (min !== undefined)
503
+ return `at least ${of(min)}`;
504
+ return max !== undefined ? `at most ${of(max)}` : undefined;
505
+ };
506
+ const extra = s.additionalProperties;
507
+ const value = extra && typeof extra === 'object'
508
+ ? (typeof extra.type === 'string' ? TYPE_PHRASE[extra.type] : typeof extra.$ref === 'string' ? refName(extra.$ref) : undefined)
509
+ : undefined;
510
+ return [
511
+ // 3.0 writes `exclusiveMinimum: true` beside `minimum`; 3.1 makes it the bound.
512
+ n('minimum') !== undefined ? `${s.exclusiveMinimum === true ? 'more than' : 'at least'} ${n('minimum')}` : undefined,
513
+ n('exclusiveMinimum') !== undefined ? `more than ${n('exclusiveMinimum')}` : undefined,
514
+ n('maximum') !== undefined ? `${s.exclusiveMaximum === true ? 'less than' : 'at most'} ${n('maximum')}` : undefined,
515
+ n('exclusiveMaximum') !== undefined ? `less than ${n('exclusiveMaximum')}` : undefined,
516
+ n('multipleOf') !== undefined ? `a multiple of ${n('multipleOf')}` : undefined,
517
+ span(n('minLength'), n('maxLength'), 'character'),
518
+ typeof s.pattern === 'string' ? `pattern \`${s.pattern}\`` : undefined,
519
+ // int32, int64 and double say how a number is stored, not what to send.
520
+ typeof s.format === 'string' && type === 'string' ? `format ${s.format}` : undefined,
521
+ span(n('minItems'), n('maxItems'), 'item'),
522
+ s.uniqueItems === true ? 'no duplicate items' : undefined,
523
+ span(n('minProperties'), n('maxProperties'), 'field'),
524
+ extra && typeof extra === 'object' ? `any keys${value ? `, each value ${value}` : ''}` : undefined,
525
+ ].filter((rule) => rule !== undefined);
454
526
  }
455
- function checkSchemaKeywords(schema) {
456
- for (const key of Object.keys(schema)) {
457
- if (!OPENAPI_SCHEMA_FIELDS.has(key) && !key.startsWith('x-')) {
458
- throw Error(`Unsupported JSON schema keyword ${key}`);
527
+ /** One flat schema, every reference, composition and alternative folded in.
528
+ * Rules stay on it as keywords, for `ruleText` to put into words; nested
529
+ * properties and items are left for their own turn.
530
+ *
531
+ * `nesting` counts references and compositions, separately from how deep the
532
+ * parameter itself goes: the API allows 12 levels of fields, and a document
533
+ * that reaches each level through a $ref and an allOf should not spend three. */
534
+ function resolveOpenApiSchema(raw, ctx, where, nesting = 0) {
535
+ if (nesting > 32)
536
+ throw Error('OpenAPI schema is recursive or nests references more than 32 deep');
537
+ if (typeof raw.$ref === 'string') {
538
+ const { $ref, ...siblings } = raw;
539
+ const target = refTarget($ref, ctx.doc);
540
+ // Keywords beside a $ref apply together with what it names (OpenAPI 3.1),
541
+ // which is also what a 3.0 author writing a description or a rule there means.
542
+ const next = Object.keys(siblings).length ? { ...siblings, allOf: [target, ...(siblings.allOf ?? [])] } : target;
543
+ return resolveOpenApiSchema(next, ctx, where, nesting + 1);
544
+ }
545
+ const { allOf, oneOf, anyOf, ...own } = raw;
546
+ if (allOf && !allOf.length)
547
+ throw Error('An allOf schema needs at least one member');
548
+ const parts = (allOf ?? []).map(part => resolveOpenApiSchema(part, ctx, where, nesting + 1));
549
+ const said = [];
550
+ const join = (alternatives) => {
551
+ const joined = joinAlternatives(alternatives, ctx, where, nesting + 1);
552
+ parts.push(joined.schema);
553
+ if (joined.text)
554
+ said.push(joined.text);
555
+ };
556
+ if (Array.isArray(own.type)) {
557
+ // `type: [a, b]` is anyOf by another name, and "null" in it is nullable.
558
+ const types = own.type.filter(t => t !== 'null');
559
+ if (own.type.includes('null'))
560
+ own.nullable = true;
561
+ if (types.length === 1)
562
+ own.type = types[0];
563
+ else if (types.length) {
564
+ delete own.type;
565
+ join(types.map(type => ({ type })));
459
566
  }
567
+ else
568
+ own.type = 'null';
460
569
  }
570
+ if (oneOf !== undefined)
571
+ join(oneOf);
572
+ if (anyOf !== undefined)
573
+ join(anyOf);
574
+ const merged = intersect(parts, own);
575
+ const description = sentences(merged.description, ...said);
576
+ return description === undefined ? merged : { ...merged, description };
461
577
  }
462
- /** Preserve the object composition used by generated request bodies. This is
463
- * not a general schema intersection: conflicting properties and closed-object
464
- * extensions need explicit configuration rather than a weakened contract. */
465
- function resolveOpenApiSchema(raw, doc, depth) {
466
- if (depth > 12)
467
- throw Error('OpenAPI schema is recursive or exceeds 12 levels');
468
- const schema = dereference(raw, doc, new Set(), checkSchemaReference);
469
- if (!schema.allOf)
470
- return schema;
471
- const { allOf, ...own } = schema;
472
- if (!allOf.length)
473
- throw Error('An allOf schema needs at least one member');
474
- const parts = [...allOf.map(part => resolveOpenApiSchema(part, doc, depth + 1)), own];
578
+ /** allOf: every part applies at once. Properties and required fields add up,
579
+ * types and enums must agree, and rules ride along to be described. A genuine
580
+ * contradiction, a value that has to be both a string and a number, is an error
581
+ * rather than a guess. Parts are flat; their properties and items are not yet. */
582
+ function intersect(parts, own) {
583
+ const all = [...parts, own];
584
+ if (all.some(part => part.prefixItems !== undefined))
585
+ throw Error('Tuple arrays (prefixItems) are not supported');
586
+ // Annotations and rules: the outer schema's own word, else the first part's.
587
+ const first = (key) => [own, ...parts].map(part => part[key]).find(value => value !== undefined);
588
+ const types = [...new Set(all.map(part => part.type).filter((t) => typeof t === 'string'))];
589
+ const numeric = types.length === 2 && types.includes('integer') && types.includes('number');
590
+ if (types.length > 1 && !numeric)
591
+ throw Error(`allOf members disagree on the type (${types.join(' and ')})`);
592
+ const enums = all.map(part => part.const !== undefined ? [part.const] : part.enum).filter(e => e !== undefined);
593
+ const values = enums.length ? enums.reduce((kept, e) => kept.filter(v => e.includes(v))) : undefined;
594
+ if (values && !values.length)
595
+ throw Error('allOf members leave no allowed value');
475
596
  const properties = {};
476
- const required = new Set();
477
- for (const part of parts) {
478
- checkSchemaKeywords(part);
479
- if ((part.type !== undefined && part.type !== 'object') || part.enum || part.nullable || part.items) {
480
- throw Error('Only non-nullable object allOf composition is supported');
481
- }
482
- if (part.additionalProperties !== undefined && typeof part.additionalProperties !== 'boolean') {
483
- throw Error('Schema-valued additionalProperties is not supported');
484
- }
597
+ for (const part of all) {
485
598
  for (const [key, property] of Object.entries(part.properties ?? {})) {
486
- if (Object.hasOwn(properties, key) && JSON.stringify(properties[key]) !== JSON.stringify(property)) {
487
- throw Error(`Overlapping allOf property ${key} needs explicit configuration`);
488
- }
489
- Object.defineProperty(properties, key, { value: property, enumerable: true, configurable: true });
599
+ const before = Object.hasOwn(properties, key) ? properties[key] : undefined;
600
+ // The same field from two parts must satisfy both, so its rules add up too.
601
+ const value = before === undefined || JSON.stringify(before) === JSON.stringify(property) ? property : { allOf: [before, property] };
602
+ Object.defineProperty(properties, key, { value, enumerable: true, configurable: true, writable: true });
490
603
  }
491
- for (const key of part.required ?? [])
492
- required.add(key);
493
604
  }
494
- const closed = parts.filter(part => part.additionalProperties === false);
495
- if (closed.some(part => Object.keys(properties).some(key => !Object.hasOwn(part.properties ?? {}, key)))) {
605
+ const extras = all.map(part => part.additionalProperties).filter(extra => extra !== undefined);
606
+ // Keys that match patternProperties are allowed even on a closed object.
607
+ const closed = extras.includes(false) && !all.some(part => part.patternProperties !== undefined);
608
+ if (closed && all.some(part => part.additionalProperties === false && Object.keys(properties).some(key => !Object.hasOwn(part.properties ?? {}, key)))) {
496
609
  throw Error('Extending a closed object with allOf is not supported');
497
610
  }
611
+ const items = all.map(part => part.items).filter((item) => item !== undefined);
612
+ const required = [...new Set(all.flatMap(part => part.required ?? []))];
613
+ const annotations = Object.fromEntries(['title', 'description', ...RULE_KEYWORDS]
614
+ .map(key => [key, first(key)]).filter(([, value]) => value !== undefined));
498
615
  return {
499
- ...own,
500
- type: 'object',
501
- properties,
502
- required: [...required],
503
- ...(closed.length > 0 && { additionalProperties: false }),
616
+ ...annotations,
617
+ ...(types.length && { type: numeric ? 'integer' : types[0] }),
618
+ ...(all.some(part => part.nullable === true) && { nullable: true }),
619
+ ...(values && { enum: values }),
620
+ ...(all.some(part => part.properties !== undefined) && { properties }),
621
+ ...(required.length && { required }),
622
+ ...(items.length && { items: items.length === 1 ? items[0] : { allOf: items } }),
623
+ ...(extras.length && { additionalProperties: closed ? false : extras.find(extra => typeof extra === 'object') ?? true }),
504
624
  // A read-only member still makes the composed property response-only.
505
- ...(parts.some(part => part.readOnly === true) && { readOnly: true }),
625
+ ...(all.some(part => part.readOnly === true) && { readOnly: true }),
506
626
  };
507
627
  }
508
- /** Supported JSON subset, including nested objects/arrays. Unsupported shapes
509
- * skip the operation with a warning rather than drafting a subtly wrong call. */
510
- function openApiParam(raw, doc, depth = 0) {
628
+ /** oneOf / anyOf: one shape that admits every alternative, as closely as a tool
629
+ * parameter can say it, plus the alternatives in words for the model. The API
630
+ * then refuses a value that fits none of them, as it would from any caller. */
631
+ function joinAlternatives(raw, ctx, where, nesting) {
632
+ if (!raw.length)
633
+ throw Error('oneOf and anyOf need at least one alternative');
634
+ const resolved = raw.map(alternative => resolveOpenApiSchema(alternative, ctx, where, nesting));
635
+ const onlyNull = (s) => s.type === 'null' || (!!s.enum?.length && s.enum.every(v => v === null));
636
+ const options = resolved.flatMap((schema, i) => onlyNull(schema) ? [] : [{ schema, raw: raw[i] }]);
637
+ if (!options.length)
638
+ throw Error('An alternative list that allows only null is not supported');
639
+ const nullable = options.length < resolved.length || options.some(option => option.schema.nullable === true);
640
+ const withNull = (schema) => nullable ? { ...schema, nullable: true } : schema;
641
+ // "X or null" is how most generators write an optional field.
642
+ if (options.length === 1)
643
+ return { schema: withNull(options[0].schema) };
644
+ const labels = options.map(option => alternativeLabel(option.raw, option.schema));
645
+ const types = [...new Set(options.map(option => typeOf(option.schema)).filter((t) => t !== undefined))];
646
+ const type = types.length <= 1 ? types[0]
647
+ : types.every(t => t === 'number' || t === 'integer') ? 'number'
648
+ // A string takes what any of these primitives would say, and on a query or
649
+ // path it is exactly what goes on the wire.
650
+ : types.includes('string') && types.every(t => PRIMITIVE_TYPES.has(t)) ? 'string'
651
+ : undefined;
652
+ if (types.length > 1 && type === undefined) {
653
+ // No one parameter takes both an object and a list. Every alternative is
654
+ // valid by the document's own account, so the first one is kept.
655
+ ctx.notes.add(`"${where}" accepts ${[...new Set(labels)].join(' or ')}. A tool parameter takes one shape, so it ` +
656
+ `sends ${labels[0]}; edit the tool if the agent needs another.`);
657
+ return { schema: withNull(options[0].schema) };
658
+ }
659
+ const all = options.map(option => option.schema);
660
+ const shared = new Map();
661
+ for (const schema of all) {
662
+ for (const [key, property] of Object.entries(schema.properties ?? {})) {
663
+ const seen = shared.get(key) ?? [];
664
+ if (!seen.some(s => JSON.stringify(s) === JSON.stringify(property)))
665
+ seen.push(property);
666
+ shared.set(key, seen);
667
+ }
668
+ }
669
+ const properties = {};
670
+ for (const [key, schemas] of shared) {
671
+ // A field several alternatives name may take any of their shapes.
672
+ Object.defineProperty(properties, key, { value: schemas.length === 1 ? schemas[0] : { anyOf: schemas }, enumerable: true, configurable: true, writable: true });
673
+ }
674
+ const required = all.map(schema => schema.required ?? []).reduce((kept, r) => kept.filter(key => r.includes(key)));
675
+ // Choices only when every alternative lists them, and only in one type.
676
+ const values = all.every(schema => schema.enum) && (types.length <= 1 || type === 'number')
677
+ ? [...new Set(all.flatMap(schema => schema.enum))] : undefined;
678
+ const items = all.map(schema => schema.items).filter((item) => item !== undefined);
679
+ const named = options.some(option => option.raw.$ref !== undefined || option.schema.title !== undefined);
680
+ const said = new Set(labels).size > 1 && !(values && !named);
681
+ return {
682
+ schema: withNull({
683
+ ...(type && { type }),
684
+ ...(shared.size && { properties }),
685
+ ...(required.length && { required }),
686
+ ...(values && { enum: values }),
687
+ ...(items.length && { items: items.length === 1 ? items[0] : { anyOf: items } }),
688
+ ...(all.every(schema => schema.additionalProperties === false) && { additionalProperties: false }),
689
+ }),
690
+ ...(said && { text: `One of: ${labels.join('; ')}.` }),
691
+ };
692
+ }
693
+ /** One alternative in a few words: its name, then its fields or its rules. */
694
+ function alternativeLabel(raw, s) {
695
+ const name = typeof raw.$ref === 'string' ? refName(raw.$ref) : typeof s.title === 'string' ? s.title : undefined;
696
+ const type = typeOf(s);
697
+ const fields = s.required?.length ? s.required : Object.keys(s.properties ?? {});
698
+ const shown = `with ${[...fields.slice(0, 8), ...(fields.length > 8 ? ['…'] : [])].join(', ')}`;
699
+ // A fragment that only says which fields are required reads as "with email".
700
+ const detail = type === 'object' || (type === undefined && fields.length)
701
+ ? (!fields.length ? TYPE_PHRASE.object : name || type === undefined ? shown : `${TYPE_PHRASE.object} ${shown}`)
702
+ : [
703
+ s.enum?.length === 1 ? JSON.stringify(s.enum[0]) : s.enum ? `one of ${s.enum.map(v => JSON.stringify(v)).join(', ')}` : TYPE_PHRASE[type ?? ''],
704
+ ...ruleText(s, type),
705
+ ].filter(Boolean).join(', ');
706
+ return name && detail ? `${name} (${detail})` : name ?? (detail || 'any value');
707
+ }
708
+ /** Swagger 2 writes a non-body parameter's schema on the parameter itself. */
709
+ function swaggerParamSchema(p) {
710
+ const schema = { type: p.type ?? 'string' };
711
+ for (const key of ['enum', 'items', ...RULE_KEYWORDS])
712
+ if (p[key] !== undefined)
713
+ schema[key] = p[key];
714
+ return schema;
715
+ }
716
+ /** A tool parameter from a schema. What a parameter cannot carry, a rule or a
717
+ * choice between shapes, is put into its description; a shape no parameter can
718
+ * take at all (a tuple, a recursive tree) still skips the operation, with a
719
+ * warning, rather than drafting a call that cannot be made. */
720
+ function openApiParam(raw, ctx, depth = 0, where = 'body') {
511
721
  if (depth > 12)
512
722
  throw Error('OpenAPI schema is recursive or exceeds 12 levels');
513
- const s = resolveOpenApiSchema(raw, doc, depth);
514
- // Constraints such as minimum/pattern remain fail-closed until the shared
515
- // runtime contract can enforce them; even an optional param must not silently
516
- // discard validation rules from the source document.
517
- checkSchemaKeywords(s);
518
- const types = Array.isArray(s.type) ? s.type : [s.type ?? (s.properties ? 'object' : undefined)];
519
- const concrete = types.filter(t => t !== 'null');
520
- if (concrete.length !== 1 || !['string', 'number', 'integer', 'boolean', 'object', 'array'].includes(concrete[0] ?? ''))
723
+ const s = resolveOpenApiSchema(raw, ctx, where);
724
+ const t = typeOf(s);
725
+ if (t === undefined || !PARAM_TYPES.has(t))
521
726
  throw Error('JSON schema must declare one supported type (optionally nullable)');
522
- const t = concrete[0];
523
- const param = { type: (t === 'integer' ? 'number' : t), ...(t === 'integer' && { integer: true }), ...(s.description && { description: s.description }), ...(s.enum && { choices: s.enum }), ...((s.nullable || types.includes('null')) && { nullable: true }) };
727
+ const type = (t === 'integer' ? 'number' : t);
728
+ // Choices the API would refuse, a value of another type, are described instead.
729
+ const fits = (v) => v === null || (t === 'integer' ? Number.isInteger(v) : typeof v === type);
730
+ const choices = s.enum?.length && PRIMITIVE_TYPES.has(t) && s.enum.every(fits) ? s.enum : undefined;
731
+ const rules = ruleText(s, t);
732
+ if (s.enum?.length && !choices)
733
+ rules.push(`one of ${s.enum.map(v => JSON.stringify(v)).join(', ')}`);
734
+ const description = sentences(s.description, rules.length ? `Rules: ${rules.join('; ')}.` : undefined);
735
+ const nullable = s.nullable === true || (choices?.includes(null) ?? false);
736
+ const param = { type, ...(t === 'integer' && { integer: true }), ...(description && { description }), ...(choices && { choices }), ...(nullable && { nullable: true }) };
524
737
  if (t === 'object') {
525
738
  if (Object.hasOwn(s.properties ?? {}, '__proto__'))
526
739
  throw Error('Object property __proto__ is not supported');
527
- if (s.additionalProperties !== undefined && typeof s.additionalProperties !== 'boolean')
528
- throw Error('Schema-valued additionalProperties is not supported');
740
+ // A map (`additionalProperties` holding a schema) takes any key; its rule is described.
529
741
  if (s.additionalProperties !== undefined)
530
- param.additionalProperties = s.additionalProperties;
742
+ param.additionalProperties = s.additionalProperties !== false;
531
743
  param.properties = Object.fromEntries(Object.entries(s.properties ?? {})
532
744
  .filter(([, value]) => value.readOnly !== true)
533
- .map(([key, value]) => [key, resolveOpenApiSchema(value, doc, depth + 1)])
745
+ .map(([key, value]) => [key, resolveOpenApiSchema(value, ctx, `${where}.${key}`)])
534
746
  .filter(([, value]) => value.readOnly !== true)
535
- .map(([key, value]) => [key, { ...openApiParam(value, doc, depth + 1), ...(s.required?.includes(key) && { required: true }) }]));
747
+ .map(([key, value]) => [key, { ...openApiParam(value, ctx, depth + 1, `${where}.${key}`), ...(s.required?.includes(key) && { required: true }) }]));
536
748
  for (const key of s.required ?? [])
537
749
  if (!Object.hasOwn(s.properties ?? {}, key))
538
750
  throw Error(`Required property ${key} has no schema`);
@@ -540,7 +752,7 @@ function openApiParam(raw, doc, depth = 0) {
540
752
  if (t === 'array') {
541
753
  if (!s.items)
542
754
  throw Error('Array schema needs items');
543
- param.items = openApiParam(s.items, doc, depth + 1);
755
+ param.items = openApiParam(s.items, ctx, depth + 1, `${where}[]`);
544
756
  }
545
757
  return param;
546
758
  }
@@ -614,6 +826,39 @@ function openApiSecretNames(schemes, options) {
614
826
  }
615
827
  return allocate;
616
828
  }
829
+ /** The security for an operation the document says nothing about, when it
830
+ * defines a scheme and no `security` array anywhere names one.
831
+ *
832
+ * REAL DOCUMENTS DO THIS ALL THE TIME: the scheme is declared under components
833
+ * and the `security` array is never written. Read literally, every operation is
834
+ * then public, which is how drafted tools came out with no credential and failed
835
+ * every call with a 401 nobody had been warned about. One scheme is unambiguous,
836
+ * so it applies, and the developer is told. With several, choosing would be a
837
+ * guess, so none applies and the warning says so. An operation that writes
838
+ * `security: []` stays public: that was said on purpose. */
839
+ function unlistedSecurity(doc, schemes, draft) {
840
+ const names = Object.keys(schemes);
841
+ const operations = Object.values(doc.paths ?? {}).flatMap(item => OPENAPI_METHODS.flatMap(method => item?.[method] ?? []));
842
+ const named = [doc.security, ...operations.map(op => op.security)].some(list => Array.isArray(list) && list.some(requirement => !!requirement && Object.keys(requirement).length > 0));
843
+ // Only an operation the document says nothing about takes the default.
844
+ const silent = doc.security === undefined && operations.some(op => op.security === undefined);
845
+ if (!names.length || named || !silent)
846
+ return [];
847
+ const only = names.length === 1 ? names[0] : undefined;
848
+ if (only !== undefined && supportedSecurityScheme(schemes[only])) {
849
+ draft.warnings.push({ kind: 'security-choice', message: `The document defines one security scheme, "${only}", and no operation lists it under "security", ` +
850
+ `so every tool whose operation does not set "security" sends it. Remove it from any tool that calls ` +
851
+ `a public endpoint.` });
852
+ return [{ [only]: [] }];
853
+ }
854
+ draft.warnings.push({ kind: 'security-choice', message: only !== undefined
855
+ ? `The document defines the security scheme "${only}" (${schemes[only]?.type ?? 'no type'}), which the import cannot set up, ` +
856
+ `and no operation lists it, so the tools send no credential. Add the header the API expects to each tool.`
857
+ : `The document defines ${names.length} security schemes (${names.map(n => `"${n}"`).join(', ')}) and no operation lists one ` +
858
+ `under "security", so the tools send no credential. List the one the API uses under "security" and import again, ` +
859
+ `or add the header to each tool.` });
860
+ return [];
861
+ }
617
862
  /** Draft supported OpenAPI/Swagger requests; authentication follows each
618
863
  * operation's security requirement, including AND, alternatives and security:[]. */
619
864
  export function fromOpenApi(doc, options = {}) {
@@ -623,6 +868,7 @@ export function fromOpenApi(doc, options = {}) {
623
868
  draft.warnings.push({ kind: 'undefined-variable', message: 'The document names no server. Set absolute HTTP(S) tool URLs before creating the agent.' });
624
869
  const schemes = doc.components?.securitySchemes ?? doc.securityDefinitions ?? {};
625
870
  const secretName = openApiSecretNames(schemes, options);
871
+ const unlisted = unlistedSecurity(doc, schemes, draft);
626
872
  for (const [path, item] of Object.entries(doc.paths ?? {}))
627
873
  for (const method of OPENAPI_METHODS) {
628
874
  const op = item[method];
@@ -633,12 +879,13 @@ export function fromOpenApi(doc, options = {}) {
633
879
  continue;
634
880
  const name = draft.uniqueName(candidate);
635
881
  try {
882
+ const ctx = { doc, notes: new Set() };
636
883
  const merged = new Map();
637
884
  for (const raw of [...(item.parameters ?? []), ...(op.parameters ?? [])]) {
638
885
  const p = dereference(raw, doc);
639
886
  merged.set(p.in + ':' + p.name, p);
640
887
  }
641
- const security = op.security ?? doc.security ?? [];
888
+ const security = op.security ?? doc.security ?? unlisted;
642
889
  const selected = selectSecurity(security, schemes, options, merged.values());
643
890
  const keys = Object.keys(security[selected] ?? {});
644
891
  const credentialDestinations = new Set(keys.map(key => securityDestination(schemes[key])));
@@ -661,10 +908,9 @@ export function fromOpenApi(doc, options = {}) {
661
908
  }
662
909
  if (!['path', 'query', 'header', 'body'].includes(p.in ?? ''))
663
910
  throw Error(`Unsupported parameter location ${p.in}`);
664
- const schema = p.schema ?? { type: p.type ?? 'string', ...(p.enum && { enum: p.enum }) };
665
- const def = openApiParam(schema, doc);
666
- if (p.description)
667
- def.description = p.description;
911
+ const schema = p.schema ?? swaggerParamSchema(p);
912
+ // The parameter's own description leads, and the schema's rules follow it.
913
+ const def = openApiParam(p.description ? { ...schema, description: p.description } : schema, ctx, 0, p.name);
668
914
  if (p.required || p.in === 'path')
669
915
  def.required = true;
670
916
  if (p.in !== 'body' && (['object', 'array'].includes(def.type) || (p.style && !['simple', 'form'].includes(p.style))))
@@ -688,7 +934,7 @@ export function fromOpenApi(doc, options = {}) {
688
934
  const media = Object.entries(requestBody.content ?? {}).find(([type]) => /(?:\/json|\+json)$/.test(type));
689
935
  if (!media?.[1].schema)
690
936
  throw Error('Only a JSON request body with a declared schema is supported');
691
- const def = openApiParam(media[1].schema, doc);
937
+ const def = openApiParam(media[1].schema, ctx);
692
938
  if (requestBody.required)
693
939
  def.required = true;
694
940
  const param = draft.param(params, 'body', def, name, 'requestBody');
@@ -711,6 +957,8 @@ export function fromOpenApi(doc, options = {}) {
711
957
  if (query.length)
712
958
  url += '?' + query.join('&');
713
959
  draft.tools.push({ name, description: describe([op.description, op.summary], name, method.toUpperCase(), path, draft), method: method.toUpperCase(), url, ...(Object.keys(headers).length && { headers }), ...(Object.keys(params).length && { params }), ...(draft.secretsFor(name).length && { secrets: draft.secretsFor(name) }), ...(body !== undefined && method !== 'get' && { body }) });
960
+ for (const note of ctx.notes)
961
+ draft.warnings.push({ kind: 'unsupported-body', tool: name, message: note });
714
962
  }
715
963
  catch (error) {
716
964
  draft.warnings.push({ kind: 'unsupported-operation', tool: name, message: `Skipped ${method.toUpperCase()} ${path}: ${error.message}. Configure this operation explicitly.` });