This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-25632-catalog-doc-several-options in repository https://gitbox.apache.org/repos/asf/camel.git
commit a54649334314aca83caf1a78a27c6862a0f902a3 Author: Claus Ibsen <[email protected]> AuthorDate: Thu Nov 12 16:22:14 2026 +0100 CAMEL-25632: camel-jbang - camel_catalog_doc: let one call check several options The optionsFilter of camel_catalog_doc takes several terms, separated by comma (logStackTrace,logExhausted,maximumRedeliveries), so one call checks all the options a step is about to write. The answer lists what any term matches (options, nestedOptions, functions, operators, exchange properties), and unmatchedTerms names each term that matches no option, with what the answer for that term alone says: the message header it is, where an option of that name is, or the closest options. A filter of one term answers as before. For an EIP, the options of its elements are now searched also when options of the EIP itself match: redelivery on onException gives redeliveryPolicy and its redeliveryDelay. A cut answer names the entries of a list it leaves out. The tool descriptions (shared by camel ask, camel run --mcp, the TUI and the MCP server) and the MCP server instructions say so. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 5 + .../modules/ROOT/pages/camel-jbang-mcp.adoc | 16 +- .../dsl/jbang/core/commands/ai/AuthoringTools.java | 2 +- .../dsl/jbang/core/commands/ai/CatalogDocs.java | 253 ++++++++++++++++----- .../commands/ai/CatalogDocSeveralOptionsTest.java | 193 ++++++++++++++++ .../jbang/core/commands/mcp/AuthoringTools.java | 9 +- .../core/commands/mcp/McpServerInstructions.java | 3 +- .../commands/mcp/McpServerInstructionsTest.java | 8 + 8 files changed, 421 insertions(+), 68 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index c2068729eeb0..cc474f35a9ef 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -3990,6 +3990,11 @@ set to `endpoint,component` and its `configKey`, instead of twice, and `matchedO that only matches a word of an EIP title (such as `retry`) no longer returns that EIP: the answer is a not-found with suggestions. +A comma in the `optionsFilter` of `camel_catalog_doc` now separates several terms, each matched on its own, instead +of being matched as part of the text; a term that matches no option is listed in `unmatchedTerms`. For an EIP, the +options of its elements that match the filter are now listed under `nestedOptions` also when options of the EIP +itself match, not only when none does. + === camel-wasm - the endpoint runs WASI command modules (Breaking change) The `wasm` endpoint now runs WASI Preview 1 command modules (the body is standard input, standard output becomes the diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc index 2fb95d1b16f0..4268638e9381 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc @@ -109,8 +109,9 @@ questions that no tool answers and that agents otherwise go hunting for: source that passes the schema can still fail at runtime, so run it and read the log. * Which tool answers which question, by tool name: `camel_catalog_doc`, `camel_catalog_find`, `camel_catalog_sample`, `camel_catalog_examples`, `camel_dependency_for_class`, `camel_validate_source`, - `camel_run`, `camel_get_log`, `camel_get_errors` and `camel_project_overview`; and that a Citrus test runs with - `camel test run` in the project directory, not with `camel_run`. + `camel_run`, `camel_get_log`, `camel_get_errors` and `camel_project_overview`; that the `optionsFilter` of + `camel_catalog_doc` checks several options in one call; and that a Citrus test runs with `camel test run` in the + project directory, not with `camel_run`. The first line is self-contained, as some hosts show only that line. @@ -348,9 +349,14 @@ passed as the file name, is answered with the argument the source goes in. `includeOptions` picks the options listed: `common` (the default) leaves out the deprecated and advanced ones and says how many with `omittedOptions`, `required` lists the required ones, `all` everything, `false` none; `optionsFilter` matches a keyword in the option names, descriptions and groups and searches all - options. When it matches none of an EIP's own options, the options of the EIP's elements are searched and - listed under `nestedOptions` with the YAML key of the element they go under (`logStackTrace` of `onException` - is an option of its `redeliveryPolicy`; the template parameters of `routeTemplate` go under `parameters`). For + options. It takes several terms separated by comma (`logStackTrace,logExhausted,maximumRedeliveries`), so one + call checks all the options a step is about to write: the answer lists what any of them matches, and + `unmatchedTerms` names each term that matches no option, with what the answer for that term alone says (the + message header it is, where an option of that name is, or the closest options). For an EIP, the options of + its elements are searched too and listed under `nestedOptions` with the YAML key of the element they go under + (`logStackTrace` of `onException` is an option of its `redeliveryPolicy`; the template parameters of + `routeTemplate` go under `parameters`), also when options of the EIP itself match (`redelivery` gives the + `redeliveryPolicy` of `onException` and its `redeliveryDelay`). For an element written as one of several kinds, the options of the kinds are searched: `deadLetterUri` of `errorHandler` goes under `deadLetterChannel`, and an option that several kinds have lists the others in `alsoUnder`. An element written as one of several kinds has `oneOf` with the kinds and a diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java index 57d9e74d1607..8ff920565e79 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java @@ -113,7 +113,7 @@ public final class AuthoringTools { /** Registers the authoring tools; called once by the {@link ToolRegistry}. */ static void register(Consumer<ToolDescriptor> registry) { registry.accept(tool("camel_catalog_doc", - "Catalog documentation of a component, data format, language, EIP, Kamelet, built-in bean or the Java API: description, options, Maven coordinates, the URI rules of a component; for simple its functions and operators (optionsFilter narrows them). endpoint validates a URI. A full key such as camel.server.port lists its application.properties group.") + "Catalog documentation of a component, data format, language, EIP, Kamelet, built-in bean or the Java API: description, options, Maven coordinates, the URI rules of a component; for simple its functions and operators (optionsFilter narrows them). optionsFilter checks several options in one call, comma-separated. endpoint validates a URI. A full key such as camel.server.port lists its application.properties group.") .param("name", "string", "Name, e.g. kafka, json (a data format by its YAML name or artifact), simple, timer, choice, split, Exchange", false) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java index 3b8c56ece7c5..dd7d811f045a 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java @@ -97,8 +97,10 @@ public final class CatalogDocs { + " or for simple: functions, operators, ognl, advanced"; /** The optionsFilter argument of camel_catalog_doc. */ public static final String OPTIONS_FILTER_DESC - = "Word to match in option names or descriptions, all matches listed in full; for an EIP whose own options" - + " do not match, its elements' options are searched (nestedOptions)"; + = "Word or option name to match in option names or descriptions, all matches listed in full. Several," + + " comma-separated (logStackTrace,maximumRedeliveries), check them all in one call: unmatchedTerms says" + + " what each one that matches no option is instead. For an EIP, the options of its elements are" + + " searched too (nestedOptions)"; /** The limit argument of camel_catalog_doc. */ public static final String OPTIONS_LIMIT_DESC = "Options of a component listed in full (default 20, the rest by name; all with optionsFilter)"; @@ -128,9 +130,10 @@ public final class CatalogDocs { * @param name the artifact name (kafka, json-jackson, simple, choice); ignored when endpoint is given * @param endpoint an endpoint URI to validate instead of documenting an artifact * @param kind component, dataformat, language, eip, bean or api; null to detect - * @param optionsFilter keyword to match in option names, descriptions and groups; also picks the simple functions - * and operators to list in full, and the exchange properties of an EIP (by name and - * description). When no option of a component matches, its message headers are searched; + * @param optionsFilter keyword to match in option names, descriptions and groups, or several separated by comma + * (each term that matches nothing is said in unmatchedTerms); also picks the simple + * functions and operators to list in full, and the exchange properties of an EIP (by name + * and description). When no option of a component matches, its message headers are searched; * when no function of simple matches, the file: functions; when nothing matches, the answer * says where an option or header of that name is * @param includeOptions which options to list: {@code common} (the default; without the deprecated and advanced @@ -240,8 +243,102 @@ public final class CatalogDocs { public static JsonObject catalogDoc( CamelCatalog catalog, String name, String endpoint, String kind, String optionsFilter, String includeOptions, boolean includeHeaders, boolean includeDoc, String docPage, int limit) { - return capAnswer(answer(catalog, name, endpoint, kind, optionsFilter, includeOptions, includeHeaders, includeDoc, - docPage, limit)); + String filter = filterTerms(optionsFilter); + JsonObject answer = answer(catalog, name, endpoint, kind, filter, includeOptions, includeHeaders, includeDoc, + docPage, limit); + if (several(filter) && (endpoint == null || endpoint.isBlank()) && (docPage == null || docPage.isBlank()) + && !answer.containsKey("error")) { + putUnmatchedTerms(catalog, name, kind, filter, includeOptions, limit, answer); + if (splitTerms(optionsFilter).size() > MAX_FILTER_TERMS) { + answer.put("filterNote", "only the first " + MAX_FILTER_TERMS + " terms of optionsFilter are searched"); + } + } + return capAnswer(answer); + } + + /** The most terms of one optionsFilter that are searched (CAMEL-25632). */ + static final int MAX_FILTER_TERMS = 30; + + /** + * The optionsFilter as the answer matches it: as it is when it has no comma, else its terms joined by comma. A + * model checked each option it meant to write with a call of its own, and ran out of tool calls before it wrote the + * route: 17 lookups on onException in one step of the local-model benchmark (CAMEL-25632). So the filter takes + * several terms, separated by comma, each trimmed (also of the brackets and quotes of a list written as text), in + * lower case and without duplicates, up to {@link #MAX_FILTER_TERMS}. Null when there is no filter. + */ + static String filterTerms(String optionsFilter) { + if (optionsFilter == null || optionsFilter.isBlank()) { + return null; + } + if (optionsFilter.indexOf(',') < 0) { + return optionsFilter; + } + List<String> terms = splitTerms(optionsFilter); + return terms.isEmpty() ? null : String.join(",", terms.subList(0, Math.min(terms.size(), MAX_FILTER_TERMS))); + } + + /** The spaces, brackets and quotes around a term of a filter: [a, "b"] is a and b. */ + private static final Pattern TERM_DECORATION = Pattern.compile("^[\\s\\[\"']+|[\\s\\]\"']+$"); + + private static List<String> splitTerms(String optionsFilter) { + Set<String> terms = new LinkedHashSet<>(); + for (String part : optionsFilter.split(",")) { + String term = TERM_DECORATION.matcher(part).replaceAll("").toLowerCase(Locale.ROOT); + if (!term.isEmpty()) { + terms.add(term); + } + } + return new ArrayList<>(terms); + } + + /** Whether a filter, as {@link #filterTerms(String)} returns it, has several terms. */ + private static boolean several(String filter) { + return filter != null && filter.indexOf(',') >= 0; + } + + /** Whether the text, in lower case, contains the filter, or one of its terms when it has several. */ + private static boolean containsTerm(String lowerText, String filter) { + if (!several(filter)) { + return lowerText.contains(filter); + } + for (String term : filter.split(",")) { + if (lowerText.contains(term)) { + return true; + } + } + return false; + } + + /** + * The terms of a filter of several that match no option, each with what the answer for that term alone says + * instead: the message header it names, where an option of that name is, or the closest options. The answer for all + * the terms lists what they match; what one term matches nothing would be lost in it (CAMEL-25632). + */ + private static void putUnmatchedTerms( + CamelCatalog catalog, String name, String kind, String filter, String includeOptions, int limit, + JsonObject answer) { + JsonArray unmatched = new JsonArray(); + for (String term : filter.split(",")) { + JsonObject one = answer(catalog, name, null, kind, term, includeOptions, false, false, null, limit); + boolean headersOnly = one.getIntegerOrDefault("matchedOptions", -1) == 0 + && one.getIntegerOrDefault("matchedHeaders", 0) > 0; + if (!headersOnly && !matchedNothing(one)) { + continue; + } + JsonObject entry = new JsonObject(); + entry.put("term", term); + for (String key : List.of("note", "headers", "headerNames", "closestOptions", "elsewhere")) { + if (one.containsKey(key)) { + entry.put(key, one.get(key)); + } + } + unmatched.add(entry); + } + if (!unmatched.isEmpty()) { + answer.put("unmatchedTerms", unmatched); + answer.put("unmatchedHint", "the terms of optionsFilter that match no option here, each with what it is" + + " instead (a message header, an option elsewhere) or the closest options"); + } } private static JsonObject answer( @@ -701,12 +798,13 @@ public final class CatalogDocs { // only when there is one, else an agent spends a call that answers "no sample" (CAMEL-25539) result.put("sampleHint", "camel_catalog_sample name=" + model.getName() + " returns a validated YAML example"); } - // only when the filter matched none of the EIP's own options, also none the scope left out - if (filter != null && scope != OptionScope.NONE && result.getIntegerOrDefault("matchedOptions", 0) == 0 - && result.getIntegerOrDefault("omittedOptions", 0) == 0) { + // also when the filter matched options of the EIP itself: redelivery matches the redeliveryPolicy of + // onException, and the redeliveryDelay a model wants is an option of that element (CAMEL-25632); but not when + // the scope left out options of the EIP itself that matched + if (filter != null && scope != OptionScope.NONE && result.getIntegerOrDefault("omittedOptions", 0) == 0) { putNestedOptions(catalog, model, filter, scope, result); } - if (filter != null && matchedNothing(result)) { + if (filter != null && !several(filter) && matchedNothing(result)) { // the EIP's own options first, as a component's, then where an option of that name is JsonArray closest = closestOptions(model, filter); if (!closest.isEmpty()) { @@ -2110,7 +2208,8 @@ public final class CatalogDocs { int matching = filter != null ? sectionsContaining(how.sections(), filter) : 0; result.put("sectionsHint", "docPage=<section title> returns that section of the page as text" + (matching > 0 - ? "; the first " + matching + " contain '" + filter + "'" + ? "; the first " + matching + " contain " + + (several(filter) ? "one of " : "") + "'" + filter + "'" : "")); } String mainGroup = MAIN_CONFIG_GROUPS.get(model.getScheme()); @@ -2184,7 +2283,7 @@ public final class CatalogDocs { } if (opt.isRequired()) { required.add(o); - } else if (filter != null && opt.getName().toLowerCase(Locale.ROOT).contains(filter)) { + } else if (filter != null && containsTerm(opt.getName().toLowerCase(Locale.ROOT), filter)) { // a name match before a match in a description byName.add(o); } else { @@ -2211,7 +2310,8 @@ public final class CatalogDocs { String omittedHint = result.getString("optionsHint"); result.put("optionsHint", optionsHint(listed, ordered.size(), omittedHint)); } - if (filter != null && ordered.isEmpty() && omitted == 0) { + // a filter of several terms says this for each term that matches nothing, in unmatchedTerms + if (filter != null && !several(filter) && ordered.isEmpty() && omitted == 0) { List<ComponentModel.EndpointHeaderModel> headers = headersMatching(model, filter); if (!headers.isEmpty()) { putMatchedHeaders(result, headers, filter); @@ -2334,7 +2434,7 @@ public final class CatalogDocs { * restConfiguration (CAMEL-26572). Nothing when no option or header has that name. */ private static void putElsewhere(CamelCatalog catalog, JsonObject result, String filter) { - if (filter == null || !matchedNothing(result)) { + if (filter == null || several(filter) || !matchedNothing(result)) { return; } String where = CatalogNameIndex.of(catalog).where(filter); @@ -2563,7 +2663,7 @@ public final class CatalogDocs { JsonArray first = new JsonArray(); JsonArray rest = new JsonArray(); for (DocSection s : sections) { - if (filter != null && s.own().toLowerCase(Locale.ROOT).contains(filter)) { + if (filter != null && containsTerm(s.own().toLowerCase(Locale.ROOT), filter)) { first.add(s.title()); } else { rest.add(s.title()); @@ -2574,7 +2674,7 @@ public final class CatalogDocs { } private static int sectionsContaining(List<DocSection> sections, String filter) { - return (int) sections.stream().filter(s -> s.own().toLowerCase(Locale.ROOT).contains(filter)).count(); + return (int) sections.stream().filter(s -> containsTerm(s.own().toLowerCase(Locale.ROOT), filter)).count(); } /** @@ -2601,11 +2701,14 @@ public final class CatalogDocs { return null; } + /** How many of the entries a cut answer leaves out it names in its note. */ + private static final int MAX_CUT_NAMES = 30; + /** * Cuts an answer longer than {@link #MAX_ANSWER_LENGTH} characters, with a marker that says what was cut and how to * ask for it: first the text of a page, at a line and down to {@link #MIN_DOC_KEPT} characters, with the titles of * the sections left out; then the longest list, from its end (the options left out are named in moreOptions, the - * message headers in moreHeaders). + * message headers in moreHeaders, the entries of another list in the note). */ static JsonObject capAnswer(JsonObject answer) { String doc = answer.getString("doc"); @@ -2629,6 +2732,7 @@ public final class CatalogDocs { answer.put("truncated", true); } Map<String, Integer> cut = new LinkedHashMap<>(); + Map<String, List<String>> cutNames = new LinkedHashMap<>(); for (int round = 0; round < 10; round++) { excess = answer.toJson().length() - MAX_ANSWER_LENGTH; if (excess <= 0) { @@ -2697,9 +2801,17 @@ public final class CatalogDocs { + " optionsFilter=<header name> returns one in full"); continue; } + // the options cut are named, so that a model that asked for several by name knows which ones to ask + // again for (CAMEL-25632) + List<String> names = cutNames.computeIfAbsent(longest, k -> new ArrayList<>()); + names.addAll(0, removedNames); + String named = names.isEmpty() + ? "" + : " (" + String.join(", ", names.subList(0, Math.min(names.size(), MAX_CUT_NAMES))) + + (names.size() > MAX_CUT_NAMES ? ", ..." : "") + ")"; answer.put("truncatedNote", "the answer is cut at " + MAX_ANSWER_LENGTH + " characters: the last " + total - + " entries of " + longest + " are left out; optionsFilter=<word> or a" - + " smaller limit narrows the answer"); + + " entries of " + longest + " are left out" + named + + "; optionsFilter=<word> or a smaller limit narrows the answer"); } return answer; } @@ -2856,25 +2968,39 @@ public final class CatalogDocs { if (functions != null && !functions.isEmpty()) { result.put("functionCount", functions.size()); if (filter != null) { - boolean group = isFunctionGroup(functions, filter); - JsonArray matched = functionsMatching(functions, filter, group); + // each term of a filter of several is matched on its own, as a function name, a group or a word + JsonArray matched = new JsonArray(); + Set<String> names = new HashSet<>(); + List<String> groups = new ArrayList<>(); String note = null; - if (matched.isEmpty() && included != null && included.getFunctions() != null) { - // the file: functions are in the catalog entry of the file language, not in the one of simple: a - // model sent optionsFilter=file:name.noext 14 times in one step (CAMEL-26572) - group = isFunctionGroup(included.getFunctions(), filter); - matched = functionsMatching(included.getFunctions(), filter, group); - if (!matched.isEmpty()) { - note = "functions of the " + included.getName() + " language, which " + model.getName() - + " includes: write one inside ${...}, such as ${" - + ((JsonObject) matched.get(0)).getString("name") + "}; they work on a message from a" - + " file, ftp or sftp consumer"; + for (String term : filter.split(",")) { + boolean group = isFunctionGroup(functions, term); + JsonArray found = functionsMatching(functions, term, group); + if (found.isEmpty() && included != null && included.getFunctions() != null) { + // the file: functions are in the catalog entry of the file language, not in the one of + // simple: a model sent optionsFilter=file:name.noext 14 times in one step (CAMEL-26572) + group = isFunctionGroup(included.getFunctions(), term); + found = functionsMatching(included.getFunctions(), term, group); + if (!found.isEmpty() && note == null) { + note = "functions of the " + included.getName() + " language, which " + model.getName() + + " includes: write one inside ${...}, such as ${" + + ((JsonObject) found.get(0)).getString("name") + "}; they work on a message from" + + " a file, ftp or sftp consumer"; + } + } + if (group) { + groups.add(term); + } + for (Object fn : found) { + if (names.add(((JsonObject) fn).toJson())) { + matched.add(fn); + } } } result.put("functions", matched); result.put("matchedFunctions", matched.size()); - if (group) { - result.put("functionGroup", filter); + if (!groups.isEmpty()) { + result.put("functionGroup", String.join(",", groups)); } if (note != null) { result.put("functionsNote", note); @@ -2895,27 +3021,40 @@ public final class CatalogDocs { if (operators != null && !operators.isEmpty()) { result.put("operatorCount", operators.size()); if (filter != null) { - // an operator kind (binary, logical, unary...) is that kind only; an operator itself comes first - boolean kind = operators.stream().anyMatch(op -> filter.equalsIgnoreCase(op.getOperatorKind())); - JsonArray exact = new JsonArray(); - JsonArray arr = new JsonArray(); - for (LanguageModel.LanguageOperatorModel op : operators) { - if (kind) { - if (filter.equalsIgnoreCase(op.getOperatorKind())) { + JsonArray matched = new JsonArray(); + Set<String> names = new HashSet<>(); + List<String> kinds = new ArrayList<>(); + for (String term : filter.split(",")) { + // an operator kind (binary, logical, unary...) is that kind only; an operator itself comes first + boolean kind = operators.stream().anyMatch(op -> term.equalsIgnoreCase(op.getOperatorKind())); + JsonArray exact = new JsonArray(); + JsonArray arr = new JsonArray(); + for (LanguageModel.LanguageOperatorModel op : operators) { + if (kind) { + if (term.equalsIgnoreCase(op.getOperatorKind())) { + arr.add(operatorToJson(op)); + } + } else if (term.equalsIgnoreCase(op.getName())) { + exact.add(operatorToJson(op)); + } else if (matchesOptionFilter(op, term) + || (op.getOperatorKind() != null && op.getOperatorKind().toLowerCase().contains(term))) { arr.add(operatorToJson(op)); } - } else if (filter.equalsIgnoreCase(op.getName())) { - exact.add(operatorToJson(op)); - } else if (matchesOptionFilter(op, filter) - || (op.getOperatorKind() != null && op.getOperatorKind().toLowerCase().contains(filter))) { - arr.add(operatorToJson(op)); + } + exact.addAll(arr); + if (kind) { + kinds.add(term); + } + for (Object op : exact) { + if (names.add(((JsonObject) op).toJson())) { + matched.add(op); + } } } - exact.addAll(arr); - result.put("operators", exact); - result.put("matchedOperators", exact.size()); - if (kind) { - result.put("operatorKind", filter); + result.put("operators", matched); + result.put("matchedOperators", matched.size()); + if (!kinds.isEmpty()) { + result.put("operatorKind", String.join(",", kinds)); } } else { JsonArray syntaxes = new JsonArray(); @@ -3073,8 +3212,8 @@ public final class CatalogDocs { private static void putExchangeProperties(JsonObject result, EipModel model, String filter) { JsonArray properties = new JsonArray(); for (EipModel.EipOptionModel p : model.getExchangeProperties()) { - if (filter == null || p.getName().toLowerCase(Locale.ROOT).contains(filter) - || (p.getDescription() != null && p.getDescription().toLowerCase(Locale.ROOT).contains(filter))) { + if (filter == null || containsTerm(p.getName().toLowerCase(Locale.ROOT), filter) + || (p.getDescription() != null && containsTerm(p.getDescription().toLowerCase(Locale.ROOT), filter))) { properties.add(exchangePropertyToJson(p)); } } @@ -3193,10 +3332,10 @@ public final class CatalogDocs { if (filter == null) { return true; } - return (opt.getName() != null && opt.getName().toLowerCase().contains(filter)) - || (opt.getDescription() != null && opt.getDescription().toLowerCase().contains(filter)) - || (opt.getGroup() != null && opt.getGroup().toLowerCase().contains(filter)) - || (opt.getLabel() != null && opt.getLabel().toLowerCase().contains(filter)); + return (opt.getName() != null && containsTerm(opt.getName().toLowerCase(), filter)) + || (opt.getDescription() != null && containsTerm(opt.getDescription().toLowerCase(), filter)) + || (opt.getGroup() != null && containsTerm(opt.getGroup().toLowerCase(), filter)) + || (opt.getLabel() != null && containsTerm(opt.getLabel().toLowerCase(), filter)); } /** One option as the tools return it; {@code scope} is component or endpoint for a component's options. */ diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocSeveralOptionsTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocSeveralOptionsTest.java new file mode 100644 index 000000000000..8a7bcbbd1164 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocSeveralOptionsTest.java @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.ai; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * CAMEL-25632: in the local-model benchmark a model checked each option it meant to write with a camel_catalog_doc call + * of its own, and ran out of tool calls before it wrote the route. The optionsFilter takes several terms, separated by + * comma, and one call answers for all of them; a term that matches nothing is said in unmatchedTerms. A filter of one + * term answers as before. + */ +class CatalogDocSeveralOptionsTest { + + private static JsonObject catalogDoc(Map<String, String> args) throws Exception { + return (JsonObject) Jsoner.deserialize( + String.valueOf(ToolRegistry.execute("camel_catalog_doc", new ToolContext(), new HashMap<>(args)))); + } + + private static List<String> names(JsonObject answer, String key) { + JsonArray arr = (JsonArray) answer.get(key); + return arr == null ? List.of() : arr.stream().map(o -> ((JsonObject) o).getString("name")).toList(); + } + + private static JsonObject unmatched(JsonObject answer, String term) { + return ((JsonArray) answer.get("unmatchedTerms")).stream().map(JsonObject.class::cast) + .filter(o -> term.equals(o.getString("term"))).findFirst().orElseThrow(); + } + + @Test + void severalOptionsOfAnEipAreCheckedInOneCall() throws Exception { + // 17 calls on onException in one step, one option each (connect-file-processing step 3) + JsonObject answer = catalogDoc(Map.of("name", "onException", "kind", "eip", + "optionsFilter", "logStackTrace,logExhausted,maximumRedeliveries,handled")); + assertThat(names(answer, "options")).contains("handled"); + assertThat(names(answer, "nestedOptions")).contains("logStackTrace", "logExhausted", "maximumRedeliveries"); + assertThat(((JsonArray) answer.get("nestedOptions")).stream().map(JsonObject.class::cast) + .filter(o -> "maximumRedeliveries".equals(o.getString("name"))) + .map(o -> o.getString("under")).toList()).containsExactly("redeliveryPolicy"); + assertThat(answer.get("unmatchedTerms")).isNull(); + assertThat(answer.get("closestOptions")).isNull(); + } + + @Test + void severalOptionsOfAComponentAreCheckedInOneCall() throws Exception { + // 13 calls on sql in one step (connect-service-sql step 1) + JsonObject answer = catalogDoc(Map.of("name", "sql", + "optionsFilter", "batch, onConsume ,onConsumeFailed,useMessageBodyForSql")); + assertThat(names(answer, "options")) + .contains("batch", "onConsume", "onConsumeFailed", "useMessageBodyForSql"); + assertThat(answer.get("unmatchedTerms")).isNull(); + assertThat(answer.getString("sectionsHint")).doesNotContain("contain '"); + } + + @Test + void aTermThatMatchesNothingIsSaidWithTheClosestOptions() throws Exception { + JsonObject answer = catalogDoc(Map.of("name", "sql", "optionsFilter", "onConsume,datasorce")); + assertThat(names(answer, "options")).contains("onConsume"); + JsonObject term = unmatched(answer, "datasorce"); + assertThat(names(term, "closestOptions")).contains("dataSource"); + assertThat(term.getString("note")).contains("no option matches 'datasorce'"); + // the answer itself does not say that nothing matched + assertThat(answer.get("note")).isNull(); + assertThat(answer.get("closestOptions")).isNull(); + assertThat(answer.getString("unmatchedHint")).contains("match no option"); + + // nothing matches: each term is said + JsonObject none = catalogDoc(Map.of("name", "onException", "kind", "eip", "optionsFilter", "foox,barx")); + assertThat(((JsonArray) none.get("unmatchedTerms"))).hasSize(2); + assertThat(none.get("nestedOptions")).isNull(); + } + + @Test + void aTermThatIsAMessageHeaderSaysSo() throws Exception { + JsonObject answer = catalogDoc(Map.of("name", "aws2-s3", "optionsFilter", "bucketNameOrArn,CamelAwsS3Key")); + assertThat(names(answer, "options")).contains("bucketNameOrArn"); + JsonObject term = unmatched(answer, "camelawss3key"); + assertThat(names(term, "headers")).contains("CamelAwsS3Key"); + assertThat(term.getString("note")).contains("message headers do"); + assertThat(answer.get("matchedHeaders")).isNull(); + } + + @Test + void aTermThatIsAnOptionElsewhereSaysWhere() throws Exception { + // apiContextPath is an option of restConfiguration, not of the rest component (CAMEL-26572) + JsonObject answer = catalogDoc(Map.of("name", "rest", "kind", "component", + "optionsFilter", "apiContextPath,host")); + assertThat(unmatched(answer, "apicontextpath").getString("elsewhere")).contains("restConfiguration (eip)"); + assertThat(answer.get("elsewhere")).isNull(); + } + + @Test + void severalFunctionsOfSimpleAreCheckedInOneCall() throws Exception { + JsonObject answer = catalogDoc(Map.of("name", "simple", "kind", "language", "optionsFilter", "random,uuid")); + assertThat(names(answer, "functions")).contains("random(min,max)", "uuid(type)"); + assertThat(answer.getInteger("matchedFunctions")).isEqualTo(names(answer, "functions").size()); + // a function name comes first for its term, and is listed once + assertThat(names(answer, "functions").stream().filter("random(min,max)"::equals)).hasSize(1); + } + + @Test + void aListWrittenAsTextIsSeveralTerms() throws Exception { + JsonObject answer = catalogDoc(Map.of("name", "onException", "kind", "eip", + "optionsFilter", "[\"logStackTrace\", \"logExhausted\"]")); + assertThat(names(answer, "nestedOptions")).contains("logStackTrace", "logExhausted"); + assertThat(answer.get("unmatchedTerms")).isNull(); + } + + @Test + void oneTermAnswersAsBefore() throws Exception { + // a trailing comma leaves one term + JsonObject one = catalogDoc(Map.of("name", "sql", "optionsFilter", "datasorce")); + JsonObject trailing = catalogDoc(Map.of("name", "sql", "optionsFilter", "datasorce,")); + assertThat(trailing.toJson()).isEqualTo(one.toJson()); + assertThat(one.getString("note")).contains("no option matches 'datasorce'"); + assertThat(names(one, "closestOptions")).contains("dataSource"); + assertThat(one.get("unmatchedTerms")).isNull(); + + JsonObject header = catalogDoc(Map.of("name", "aws2-s3", "optionsFilter", "CamelAwsS3Key")); + assertThat(header.getInteger("matchedHeaders")).isPositive(); + assertThat(header.get("unmatchedTerms")).isNull(); + } + + @Test + void theElementsOfAnEipAreSearchedAlsoWhenItsOwnOptionsMatch() throws Exception { + // redelivery matches redeliveryPolicy of onException itself, and the options a model wants are in that element + JsonObject answer = catalogDoc(Map.of("name", "onException", "kind", "eip", "optionsFilter", "redelivery")); + assertThat(names(answer, "options")).contains("redeliveryPolicy"); + assertThat(names(answer, "nestedOptions")).contains("redeliveryDelay"); + assertThat(answer.getString("nestedHint")).contains("redeliveryPolicy: {"); + } + + @Test + void aCutAnswerNamesTheEntriesItLeavesOut() throws Exception { + // a matches nearly every option of kafka, more than the answer has room for + JsonObject answer = catalogDoc(Map.of("name", "kafka", "optionsFilter", "a,e")); + assertThat(answer.toJson().length()).isLessThanOrEqualTo(CatalogDocs.MAX_ANSWER_LENGTH); + assertThat(answer.getBoolean("truncated")).isTrue(); + assertThat(answer.getString("truncatedNote")).containsPattern("entries of options are left out \\(\\w+, "); + } + + @Test + void theTermsAreTrimmedLowerCasedAndUnique() { + assertThat(CatalogDocs.filterTerms(" LogStackTrace , logstacktrace,,'logExhausted' ")) + .isEqualTo("logstacktrace,logexhausted"); + assertThat(CatalogDocs.filterTerms("logStackTrace")).isEqualTo("logStackTrace"); + assertThat(CatalogDocs.filterTerms(" , ")).isNull(); + assertThat(CatalogDocs.filterTerms(null)).isNull(); + } + + @Test + void theNumberOfTermsIsBounded() throws Exception { + String filter = IntStream.rangeClosed(1, CatalogDocs.MAX_FILTER_TERMS + 5).mapToObj(i -> "term" + i) + .collect(Collectors.joining(",")); + assertThat(CatalogDocs.filterTerms(filter).split(",")).hasSize(CatalogDocs.MAX_FILTER_TERMS); + JsonObject answer = catalogDoc(Map.of("name", "sql", "optionsFilter", filter)); + assertThat(answer.getString("filterNote")).contains("only the first " + CatalogDocs.MAX_FILTER_TERMS); + assertThat(answer.toJson().length()).isLessThanOrEqualTo(CatalogDocs.MAX_ANSWER_LENGTH); + } + + @Test + void theToolDescriptionSaysSeveralCanBeChecked() { + // the shared description, which the MCP server uses too + String description = ToolRegistry.findTool("camel_catalog_doc").params().stream() + .filter(p -> "optionsFilter".equals(p.name())).findFirst().orElseThrow().description(); + assertThat(description).isEqualTo(CatalogDocs.OPTIONS_FILTER_DESC).contains("comma-separated"); + } +} diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java index ab7d43abb816..ca07981350f4 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java @@ -87,10 +87,11 @@ public class AuthoringTools { description = "Camel catalog documentation of a component, data format, language, EIP, Kamelet, built-in bean or the " + "Java API (description, options, Maven coordinates), with the URI rules of a component. For " + "simple also its syntax rules, functions and operators: count and names by group, or with " - + "optionsFilter the matching ones with parameters and examples. kind=api is the Java API to " - + "call from a bean or script (Exchange, Message, CamelContext, AggregationStrategy, ...) and " - + "the variables a groovy, js, python or java script sees. endpoint validates a URI: unknown or " - + "invalid options, missing path. An EIP alias such as fan-out or dedup finds the EIP. " + + "optionsFilter the matching ones with parameters and examples. optionsFilter checks several " + + "options in one call, comma-separated (logStackTrace,maximumRedeliveries). kind=api is the " + + "Java API to call from a bean or script (Exchange, Message, CamelContext, AggregationStrategy, " + + "...) and the variables a groovy, js, python or java script sees. endpoint validates a URI: " + + "unknown or invalid options, missing path. An EIP alias such as fan-out or dedup finds the EIP. " + "includeHeaders=true adds the message headers of a component, includeDoc=true the AsciiDoc " + "page, docPage one of its sections. A component option carries its application.properties " + "key (configKey); a full key (camel.server.port, camel.jbang.dependencies) lists its group.") diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructions.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructions.java index 763d114a306f..f5c052f59ea3 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructions.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructions.java @@ -73,7 +73,8 @@ public class McpServerInstructions implements InitialResponseInfo { calling it done. Which tool: - - options, URI syntax, headers of a component, EIP, data format or language: camel_catalog_doc + - options, URI syntax, headers of a component, EIP, data format or language: camel_catalog_doc \ + (optionsFilter checks several options in one call: a,b,c) - which component for a protocol or product: camel_catalog_find - a validated YAML sample and where it goes: camel_catalog_sample - complete example projects: camel_catalog_examples diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructionsTest.java b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructionsTest.java index c4bb6d78031e..cdec38c33ad0 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructionsTest.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpServerInstructionsTest.java @@ -81,6 +81,14 @@ class McpServerInstructionsTest { + " directory, not camel_run"); } + @Test + void severalOptionsAreCheckedInOneCatalogDocCall() { + String text = new McpServerInstructions().text(); + + // a model checked each option with a call of its own and ran out of calls (CAMEL-25632) + assertThat(text).contains("camel_catalog_doc (optionsFilter checks several options in one call: a,b,c)"); + } + @Test void everyMappedToolIsMentionedAndIsAToolOfThisServer() throws IOException { String text = new McpServerInstructions().text();
