This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 47e00007ca60 CAMEL-26690: camel-jbang - AI tools: jsonpath compiled at
write time, and the expression tool on a JSON text body (#29327)
47e00007ca60 is described below
commit 47e00007ca60d63cccf923d7a003414791a34b68
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 16:32:30 2026 +0100
CAMEL-26690: camel-jbang - AI tools: jsonpath compiled at write time, and
the expression tool on a JSON text body (#29327)
* CAMEL-26690: camel-jbang - AI tools: compile a jsonpath expression at
write time
A jsonpath that does not compile was only seen at runtime. r28
rest-code-first step 1: $[?(@.orderId == '${header.id}')][] was
accepted, then failed with "Could not parse token starting at position
29" (16 errors in r28n-3, 2 in r28n-6). The write is now refused with
the JsonPath error, the place in the text as written, and for [] the
expression without it.
JsonPath is not on the classpath of the CLI or the MCP server, so the
check compiles with the JsonPath of camel-jsonpath, which is downloaded
once as for an evaluation (CAMEL-24907); when it cannot be had, the
check is skipped. The ${...} placeholders are filled in first, with a
number and with a word, and only an error JsonPath places outside them
is reported. The easy predicate syntax of a when or filter is left to
camel-jsonpath, which turns it into a filter first.
The tests compile with json-path on the test classpath, so they do not
download.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
* CAMEL-26690: camel-jbang - AI tools: the expression tool reads the fields
of a JSON text body, and repeated evaluations get the note
camel_eval_expression takes the body as text. r28 connect-stock-api
step 2: body.find { it.sku == 'ABC' } on a body of JSON text failed 21
times with "the body is text: unmarshal it first", which the tool
cannot do, and the step ended with no write. In http-client step 1 the
same answer made the model add an unmarshal to a route whose body was a
Map already.
When an expression that reads the body fails on a body that is the text
of a JSON object or array, it is now evaluated on that text parsed as
unmarshal: json gives it to a route (maps, lists, Integer, Double), also
when it failed in the running integration. The answer says so, and what
the expression gives on the text.
The same evaluation asked again and again now gets the repeated-call
note the catalog tools get: body.getClass().getName() was evaluated 9
times with the same arguments in that step. Not for an expression that
reads the clock, a random value, an id, a property, a bean or a file,
whose answer can change. ToolDescriptor takes a condition for that, and
the MCP server passes the connection to count the repeats.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
dsl/camel-jbang/camel-jbang-core/pom.xml | 8 +
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 5 +
.../core/commands/ai/ExpressionEvaluator.java | 170 ++++++++++++++++++++-
.../dsl/jbang/core/commands/ai/JsonPathChecks.java | 165 +++++++++++++++++++-
.../dsl/jbang/core/commands/ai/ToolDescriptor.java | 22 +++
.../core/commands/ai/EvalInTheIntegrationTest.java | 26 ++++
.../ai/ExpressionEvaluatorLanguageTest.java | 38 +++++
.../jbang/core/commands/ai/JsonPathChecksTest.java | 76 +++++++++
.../core/commands/ai/RepeatedToolCallsTest.java | 31 ++++
.../jbang/core/commands/mcp/AuthoringTools.java | 6 +-
.../core/commands/mcp/AuthoringToolsTest.java | 46 +++++-
11 files changed, 571 insertions(+), 22 deletions(-)
diff --git a/dsl/camel-jbang/camel-jbang-core/pom.xml
b/dsl/camel-jbang/camel-jbang-core/pom.xml
index 0360570bf76d..f15e1a3143be 100644
--- a/dsl/camel-jbang/camel-jbang-core/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-core/pom.xml
@@ -257,6 +257,14 @@
<artifactId>camel-jq</artifactId>
<scope>test</scope>
</dependency>
+ <!-- the JsonPath a jsonpath expression of a route is compiled with
(the CLI has it from camel-jsonpath,
+ downloaded as for an evaluation) -->
+ <dependency>
+ <groupId>com.jayway.jsonpath</groupId>
+ <artifactId>json-path</artifactId>
+ <version>${json-path-version}</version>
+ <scope>test</scope>
+ </dependency>
<!-- the test schema a Citrus test is checked against; on the
classpath of the tests, so it is not downloaded
(the test plugin brings it to the CLI) -->
<dependency>
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 2b77c8cce127..1b0d5319ceb3 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
@@ -411,6 +411,11 @@ public final class AuthoringTools {
.param("predicate", "boolean", EVAL_PREDICATE_DESC, false)
.param("resultType", "string", EVAL_RESULT_TYPE_DESC, false)
.core(true)
+ // the same expression on the same message gets the same
answer, unless it reads the clock, a random
+ // value or the integration: a model that evaluates it again
and again is stuck
+ .deterministicIf(ExpressionEvaluator::sameAnswer)
+ .repeatHint("The same expression on the same message gives the
same result: change the expression or"
+ + " the message (body, headers, variables) to
learn something new.")
.executor((ctx, args) -> {
String expression = required(args, "expression");
Map<String, Object> headers =
ExpressionEvaluator.parseValues("headers", args.get("headers"));
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluator.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluator.java
index 6df292ee6053..2ac150fcd553 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluator.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluator.java
@@ -16,9 +16,13 @@
*/
package org.apache.camel.dsl.jbang.core.commands.ai;
+import java.math.BigDecimal;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -27,6 +31,7 @@ import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.Expression;
import org.apache.camel.Predicate;
+import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.LanguageValidationResult;
import org.apache.camel.dsl.jbang.core.common.ActionClient;
import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
@@ -179,11 +184,20 @@ public final class ExpressionEvaluator {
Request r = new Request(lang, expression, body, request.headers(),
request.variables(), predicate, resultType);
if (ctx.hasProcess()) {
result.put("evaluatedIn", "the running integration (pid " +
ctx.pid() + ")");
- evaluateInProcess(ctx, r, result);
+ try {
+ evaluateInProcess(ctx, r, result);
+ } catch (ToolExecutionException e) {
+ if (!evaluateOnParsedBody(ctx, r, result, e.getMessage())) {
+ throw e;
+ }
+ }
} else {
result.put("evaluatedIn", "a local scratch context (no integration
selected)");
evaluateLocally(ctx, r, result);
}
+ if ("error".equals(result.getString("status"))) {
+ evaluateOnParsedBody(ctx, r, result, result.getString("error"));
+ }
addNullAndElvisNotes(ctx, result, lang, expression);
addFilterNote(result, lang, expression);
addXPathHint(result, lang, expression);
@@ -193,6 +207,111 @@ public final class ExpressionEvaluator {
static final String NULL_NOTE = "evaluated to null: a header, property or
variable that is not set, or a function"
+ " name that took in the rest of the
text";
+ /** The languages whose expressions read the fields of a body that is
data, a Map or a List, and not of text. */
+ private static final Set<String> READS_DATA = Set.of("simple", "groovy",
"mvel", "ognl", "spel", "js", "javascript",
+ "python", "java", "joor", "jactl");
+
+ /**
+ * What an expression can read that changes from one call to the next: the
clock, a random value, an id, a property,
+ * a bean, a file, or the context or system of the integration.
+ */
+ private static final Pattern CHANGES =
Pattern.compile("(?i)random|date|time|now|uuid|\\bid\\b|exchangeId|messageId"
+ +
"|propert|\\{\\{|bean|ref:|resource:|file|context|system|env"
+ +
"|sys\\.|instant|clock|math|counter");
+
+ /**
+ * Whether an evaluation gives the same answer each time it is asked with
the same arguments, so that asking again
+ * cannot tell anything new ({@link RepeatedToolCalls}): not when the
expression reads the clock, a random value, an
+ * id, a property, a bean, a file or the context. r28 connect-stock-api
step 2: body.getClass().getName() was
+ * evaluated 10 times, 9 of them with the same arguments, up to the call
limit of the step.
+ */
+ static boolean sameAnswer(Map<String, ?> args) {
+ Object expression = args != null ? args.get("expression") : null;
+ return expression != null && !expression.toString().isBlank()
+ && !CHANGES.matcher(expression.toString()).find();
+ }
+
+ /**
+ * Evaluates again with the body parsed, when the expression read the body
and failed on a body that is JSON text: a
+ * route has that body as data (a Map or a List) after unmarshal: json,
and in a split over such data. The tool
+ * takes the body as text, so its answer "unmarshal it first" was advice
the tool could not follow: r28
+ * connect-stock-api step 2, 21 evaluations of body.find { it.sku == 'ABC'
} on a JSON text body and no write; and
+ * http-client step 1, where the model then added an unmarshal to a route
whose body was a Map already.
+ *
+ * @return true when the expression gave a value on the parsed body: the
result is that value, with a note that says
+ * so and what the expression gives on the text
+ */
+ private static boolean evaluateOnParsedBody(ToolContext ctx, Request r,
JsonObject result, String error) {
+ if (!READS_DATA.contains(r.language()) || r.expression() == null ||
!r.expression().contains("body")) {
+ return false;
+ }
+ Object data = jsonData(r.body());
+ if (data == null) {
+ return false;
+ }
+ JsonObject parsed = new JsonObject();
+ evaluateLocally(ctx, r, data, parsed);
+ if (!"ok".equals(parsed.getString("status"))) {
+ return false;
+ }
+ result.remove("error");
+ result.remove("syntaxError");
+ result.remove("syntaxErrorAt");
+ result.put("status", "ok");
+ result.put("result", parsed.get("result"));
+ result.put("evaluatedIn", "a local scratch context, with the body
parsed from its JSON text");
+ if (parsed.get("downloaded") != null) {
+ result.put("downloaded", parsed.get("downloaded"));
+ }
+ String failure = error != null ?
error.lines().findFirst().orElse("").strip() : "";
+ addNote(result, "the body is JSON text, so this is the result on that
text parsed into a "
+ + (data instanceof Map ? "Map" : "List") + ", as a
route has the body after unmarshal: json, or in"
+ + " a split over that data"
+ + (failure.isEmpty() ? "" : "; on the text itself it
fails: " + failure));
+ return true;
+ }
+
+ /**
+ * The body as data when it is the text of a JSON object or array, else
null: as unmarshal: json gives it to a
+ * route, maps, lists, text, whole numbers as Integer or Long, and the
others as Double.
+ */
+ private static Object jsonData(String body) {
+ String text = body != null ? body.strip() : "";
+ if (!text.startsWith("{") && !text.startsWith("[")) {
+ return null;
+ }
+ try {
+ Object data = Jsoner.deserialize(text);
+ return data instanceof Map || data instanceof Collection ?
unmarshalled(data) : null;
+ } catch (DeserializationException | RuntimeException e) {
+ return null;
+ }
+ }
+
+ private static Object unmarshalled(Object value) {
+ if (value instanceof Map<?, ?> map) {
+ Map<String, Object> answer = new LinkedHashMap<>();
+ map.forEach((k, v) -> answer.put(String.valueOf(k),
unmarshalled(v)));
+ return answer;
+ }
+ if (value instanceof Collection<?> list) {
+ List<Object> answer = new ArrayList<>();
+ list.forEach(v -> answer.add(unmarshalled(v)));
+ return answer;
+ }
+ if (value instanceof BigDecimal n) {
+ if (n.scale() > 0) {
+ return n.doubleValue();
+ }
+ try {
+ return n.intValueExact();
+ } catch (ArithmeticException e) {
+ return n.toBigInteger().bitLength() < 64 ? n.longValue() :
n.toBigInteger();
+ }
+ }
+ return value;
+ }
+
/**
* Says that a value is null, and why it may be. An answer that leaves out
null fields (the MCP server does) has no
* result then, which a model read as a form that works: it wrote
${header.offset ?:0} into its SQL, which reads a
@@ -391,13 +510,18 @@ public final class ExpressionEvaluator {
}
private static void evaluateLocally(ToolContext ctx, Request r, JsonObject
result) {
+ evaluateLocally(ctx, r, r.body(), result);
+ }
+
+ /** As {@link #evaluateLocally(ToolContext, Request, JsonObject)}, with
the body to evaluate on. */
+ private static void evaluateLocally(ToolContext ctx, Request r, Object
body, JsonObject result) {
String lang = r.language();
ClassLoader tccl = Thread.currentThread().getContextClassLoader();
try {
String gav = languageArtifact(ctx, lang);
// the languages of this process (simple, constant, header, ...)
need no class loader of their own
ClassLoader known = gav != null ? DOWNLOADED.get(gav) : null;
- if (evaluateWith(known, r, result)) {
+ if (evaluateWith(known, r, body, result)) {
if (known != null) {
// downloaded by an earlier call: say so again, so the
answers of two calls read the same
result.put("downloaded", gav);
@@ -409,7 +533,7 @@ public final class ExpressionEvaluator {
// jsonpath, jq or xpath expression can be tried before it is
written (CAMEL-24907)
StringBuilder failure = new StringBuilder();
ClassLoader downloaded = download(gav, failure);
- if (downloaded == null || !evaluateWith(downloaded, r, result)) {
+ if (downloaded == null || !evaluateWith(downloaded, r, body,
result)) {
result.put("status", "error");
result.put("error", "The language '" + lang + "' is not on the
classpath of this process"
+ (gav != null ? " and " + gav + " could
not be downloaded" : "")
@@ -429,7 +553,7 @@ public final class ExpressionEvaluator {
* Evaluates with the given class loader (null for this process's own),
and answers whether the language was found;
* the result of the evaluation, or its error, is put in {@code result}.
*/
- private static boolean evaluateWith(ClassLoader loader, Request r,
JsonObject result) {
+ private static boolean evaluateWith(ClassLoader loader, Request r, Object
body, JsonObject result) {
// without the CLI connector, which would make this process, such as
the MCP server, a running integration
try (DefaultCamelContext context =
ScratchContextHelper.withoutCliConnector(new DefaultCamelContext(false))) {
if (loader != null) {
@@ -445,7 +569,7 @@ public final class ExpressionEvaluator {
return false;
}
Exchange exchange = new DefaultExchange(context);
- exchange.getMessage().setBody(r.body() != null ? r.body() : "");
+ exchange.getMessage().setBody(body != null ? body : "");
if (r.headers() != null) {
r.headers().forEach(exchange.getMessage()::setHeader);
}
@@ -577,4 +701,40 @@ public final class ExpressionEvaluator {
/** The class loaders of the languages downloaded so far, so the next call
does not download again. */
private static final Map<String, ClassLoader> DOWNLOADED = new
ConcurrentHashMap<>();
+
+ /** The languages whose download failed, for a check that tries once. */
+ private static final Set<String> NOT_DOWNLOADED =
ConcurrentHashMap.newKeySet();
+
+ /**
+ * The class loader that has the language, for a check of an expression of
a route: this process's own when it has
+ * the language, else the one its component is downloaded into, once, as
for an evaluation (a download that failed
+ * is not tried again). Null when the catalog does not know the language
or it cannot be had.
+ */
+ static ClassLoader languageLoader(CamelCatalog catalog, String lang) {
+ LanguageModel model;
+ try {
+ model = catalog.languageModel(lang);
+ } catch (Exception e) {
+ return null;
+ }
+ if (model == null || model.getJavaType() == null ||
model.getArtifactId() == null) {
+ return null;
+ }
+ ClassLoader own = ExpressionEvaluator.class.getClassLoader();
+ try {
+ Class.forName(model.getJavaType(), false, own);
+ return own;
+ } catch (ClassNotFoundException | LinkageError e) {
+ // not on the classpath of this process: its component
+ }
+ String gav = model.getGroupId() + ":" + model.getArtifactId() + ":" +
model.getVersion();
+ if (NOT_DOWNLOADED.contains(gav)) {
+ return null;
+ }
+ ClassLoader downloaded = download(gav, new StringBuilder());
+ if (downloaded == null) {
+ NOT_DOWNLOADED.add(gav);
+ }
+ return downloaded;
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecks.java
index 64fbfdc891d3..ec377f3fa03f 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecks.java
@@ -16,6 +16,9 @@
*/
package org.apache.camel.dsl.jbang.core.commands.ai;
+import java.lang.reflect.Array;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
@@ -29,11 +32,16 @@ import static
org.apache.camel.dsl.jbang.core.commands.ai.YamlLines.extractYamlV
/**
* Checks on the jsonpath expressions of a YAML route. A comparison written on
the path ($.status == 'paid') is a
* condition in a when or filter (the easy predicate syntax, CAMEL-24841) and
not a path anywhere else: there the
- * runtime fails with a Jayway message about blank characters, and the check
says what to write instead.
+ * runtime fails with a Jayway message about blank characters, and the check
says what to write instead. Any other
+ * expression is compiled with JsonPath, as the runtime does.
*/
public final class JsonPathChecks {
private static final Pattern COMPARISON = Pattern.compile("
(==|!=|<=|>=|<|>|=~|in|nin|size|empty) ");
+ // [] with nothing in it, which JsonPath does not have
+ private static final Pattern EMPTY_BRACKETS = Pattern.compile("\\[\\s*]");
+ // where a Jayway message points in the path it compiled (not "error on
position", which is in the filter)
+ private static final Pattern POSITION = Pattern.compile("(?<!on
)(position:? )(\\d+)");
// an index after a filter: $[?(@.orderId == 'ORD-1')][0]
private static final Pattern INDEX_AFTER_FILTER =
Pattern.compile("(\\[\\?\\(.*\\)])\\s*\\[\\s*-?\\d+\\s*]");
private static final String ONE_ELEMENT = "set resultType: java.util.Map
(the element when one matches, null when"
@@ -78,16 +86,19 @@ public final class JsonPathChecks {
if (text == null || text.isEmpty()) {
continue;
}
- String filter = filterProblem(text,
SimpleChecks.isPredicate(catalog, lines, i, countLeadingSpaces(line)));
+ boolean predicate = SimpleChecks.isPredicate(catalog, lines, i,
countLeadingSpaces(line));
+ String filter = filterProblem(text, predicate);
if (filter != null) {
errors.add("Line " + lineNum + ": " + filter);
continue;
}
- if (text.contains("[?(") || !COMPARISON.matcher(text).find()) {
- continue;
- }
- if (SimpleChecks.isPredicate(catalog, lines, i,
countLeadingSpaces(line))) {
- // a condition: the easy predicate syntax reads it as
$[?(@.status == 'paid')]
+ if (text.contains("[?(") || !COMPARISON.matcher(text).find() ||
predicate) {
+ // a path, or a condition (the easy predicate syntax reads
$.status == 'paid' as $[?(@.status ==
+ // 'paid')]): compiled as the runtime does
+ String compile = compileProblem(catalog, text, predicate);
+ if (compile != null) {
+ errors.add("Line " + lineNum + ": " + compile);
+ }
continue;
}
String field = text.startsWith("$.") ? text.substring(2) :
text.startsWith("$") ? text.substring(1) : text;
@@ -145,6 +156,146 @@ public final class JsonPathChecks {
return null;
}
+ /**
+ * Why the expression does not compile in JsonPath, or null when it does
or JsonPath cannot be had. Camel evaluates
+ * the ${...} placeholders first, so each one is filled in with a number
and then with a word (an index, a number to
+ * compare, or a text in quotes); an expression that compiles in neither
way is reported when JsonPath points at a
+ * place outside the placeholders, which the placeholders do not change.
r28 rest-code-first step 1: $[?(@.orderId
+ * == '${header.id}')][] was accepted, and failed at runtime with "Could
not parse token starting at position 29"
+ * (16 errors in one run).
+ *
+ * @param predicate whether it is the condition of a when or filter, where
a comparison on the path is the easy
+ * predicate syntax, which camel-jsonpath turns into a
filter first
+ */
+ static String compileProblem(CamelCatalog catalog, String text, boolean
predicate) {
+ if (predicate && !text.contains("[?(") &&
COMPARISON.matcher(text).find()) {
+ // the easy predicate syntax, which camel-jsonpath turns into a
filter first
+ return null;
+ }
+ if (text.startsWith("\"") || text.startsWith("'")) {
+ // a quote the value does not close: the line is no YAML a value
can be read from, which other checks say
+ return null;
+ }
+ Method compile = compiler(catalog);
+ if (compile == null) {
+ return null;
+ }
+ Object noFilters =
Array.newInstance(compile.getParameterTypes()[1].getComponentType(), 0);
+ Filled failed = null;
+ String error = null;
+ for (String value : text.contains("${") || text.contains("{{") ?
List.of("0", "x") : List.of("0")) {
+ Filled filled = new Filled(text, value);
+ try {
+ compile.invoke(null, filled.text, noFilters);
+ return null;
+ } catch (InvocationTargetException e) {
+ if (failed == null) {
+ failed = filled;
+ error = e.getTargetException().getMessage();
+ }
+ } catch (ReflectiveOperationException | RuntimeException e) {
+ // JsonPath cannot check it here
+ return null;
+ }
+ }
+ Matcher position = error != null ? POSITION.matcher(error) : null;
+ int at = position != null && position.find() ?
failed.original(Integer.parseInt(position.group(2))) : -1;
+ if (error == null || failed.placeholders && (at < 0 ||
failed.inPlaceholder(at))) {
+ // what does not compile may be a placeholder: Camel fills it in
first
+ return null;
+ }
+ String why = error;
+ if (at >= 0) {
+ // the position in the text as written, and the text there
+ why = error.substring(0, position.start(2)) + at + " (at "
+ + text.substring(at, Math.min(text.length(), at +
12)).strip() + ")" + error.substring(position.end(2));
+ }
+ String problem = "jsonpath " + text + " does not compile: " +
why.strip();
+ if (EMPTY_BRACKETS.matcher(text).find()) {
+ return problem + "; JsonPath has no [], and a filter or [*] gives
a list already: write "
+ + EMPTY_BRACKETS.matcher(text).replaceAll("");
+ }
+ return problem;
+ }
+
+ /**
+ * JsonPath.compile of the JsonPath library: of this process when it has
the library, else of camel-jsonpath, which
+ * is downloaded for it once, as for an evaluation (CAMEL-24907); null
when it cannot be had.
+ */
+ private static Method compiler(CamelCatalog catalog) {
+ Method compile = compiler(JsonPathChecks.class.getClassLoader());
+ if (compile == null) {
+ ClassLoader loader = ExpressionEvaluator.languageLoader(catalog,
"jsonpath");
+ compile = loader != null ? compiler(loader) : null;
+ }
+ return compile;
+ }
+
+ private static Method compiler(ClassLoader loader) {
+ try {
+ Class<?> filter = Class.forName("com.jayway.jsonpath.Predicate",
false, loader);
+ return Class.forName("com.jayway.jsonpath.JsonPath", true,
loader).getMethod("compile", String.class,
+ filter.arrayType());
+ } catch (ReflectiveOperationException | LinkageError e) {
+ return null;
+ }
+ }
+
+ /** A text with each ${...} (braces balanced) and {{...}} filled in, and
where its characters are in the text. */
+ private static final class Filled {
+ final String text;
+ final boolean placeholders;
+ private final int[] original;
+ private final List<int[]> spans = new ArrayList<>();
+ private final int length;
+
+ Filled(String written, String value) {
+ StringBuilder sb = new StringBuilder();
+ List<Integer> from = new ArrayList<>();
+ int i = 0;
+ while (i < written.length()) {
+ int end = -1;
+ if (written.startsWith("${", i)) {
+ int depth = 0;
+ for (int j = i + 1; j < written.length() && end < 0; j++) {
+ if (written.charAt(j) == '{') {
+ depth++;
+ } else if (written.charAt(j) == '}' && --depth == 0) {
+ end = j + 1;
+ }
+ }
+ } else if (written.startsWith("{{", i) &&
written.indexOf("}}", i) > 0) {
+ end = written.indexOf("}}", i) + 2;
+ }
+ if (end > 0) {
+ spans.add(new int[] { i, end });
+ for (int k = 0; k < value.length(); k++) {
+ sb.append(value.charAt(k));
+ from.add(i);
+ }
+ i = end;
+ } else {
+ sb.append(written.charAt(i));
+ from.add(i++);
+ }
+ }
+ this.text = sb.toString();
+ this.placeholders = !spans.isEmpty();
+ this.original =
from.stream().mapToInt(Integer::intValue).toArray();
+ this.length = written.length();
+ }
+
+ /** Where a position of the filled text is in the text as written. */
+ int original(int position) {
+ return position < original.length ? original[position] : length;
+ }
+
+ /** Whether a position of the text as written is in a placeholder, or
right after one. */
+ boolean inPlaceholder(int position) {
+ return spans.stream().anyMatch(s -> position >= s[0] && position
<= s[1]);
+ }
+ }
+
// the text with each filter [?(...)] taken out
private static String outsideFilters(String text) {
StringBuilder sb = new StringBuilder();
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolDescriptor.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolDescriptor.java
index 8aa4ff60ff48..3a9921263ca0 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolDescriptor.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolDescriptor.java
@@ -20,6 +20,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.function.Predicate;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
@@ -40,6 +41,7 @@ public class ToolDescriptor {
private boolean deterministic = false;
private String deterministicWhen;
private String[] deterministicUnless = new String[0];
+ private Predicate<Map<String, ?>> deterministicIf;
private String repeatHint;
/**
@@ -136,6 +138,16 @@ public class ToolDescriptor {
return this;
}
+ /**
+ * Marks the tool as deterministic for the calls the condition accepts,
such as an evaluation of an expression on
+ * the message the call gives, which answers the same each time unless the
expression reads the clock or a random
+ * value.
+ */
+ public ToolDescriptor deterministicIf(Predicate<Map<String, ?>> condition)
{
+ deterministicIf = condition;
+ return this;
+ }
+
/** What the note of a repeated call adds for this tool: what to do
instead of asking again. */
public ToolDescriptor repeatHint(String hint) {
repeatHint = hint;
@@ -185,6 +197,9 @@ public class ToolDescriptor {
if (deterministic) {
return true;
}
+ if (deterministicIf != null) {
+ return deterministicIf.test(args);
+ }
if (deterministicWhen == null || !hasValue(args, deterministicWhen)) {
return false;
}
@@ -201,6 +216,13 @@ public class ToolDescriptor {
return deterministicWhen;
}
+ /**
+ * Whether some calls of the tool are deterministic: all of them, the ones
with an argument, or as a condition says.
+ */
+ public boolean isDeterministicForSomeCalls() {
+ return deterministic || deterministicWhen != null || deterministicIf
!= null;
+ }
+
private static boolean hasValue(Map<String, ?> args, String name) {
Object v = args != null ? args.get(name) : null;
return v != null && !v.toString().isBlank();
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
index 540413bf2529..eaf28a9bf9a2 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
@@ -250,6 +250,32 @@ class EvalInTheIntegrationTest {
}
}
+ /**
+ * A field read on a body passed as JSON text fails in the integration too
(r28 connect-stock-api step 2): it is
+ * read on the parsed body, in a local context, with the error of the
integration in the note.
+ */
+ @Test
+ void aFieldOfAJsonTextBodyIsReadOnTheParsedData() throws Exception {
+ String error = "No such property: sku for class: java.lang.String (the
body is text (a String), not parsed data:"
+ + " unmarshal it first (unmarshal: json) to read its
fields)";
+ try (FakeIntegration failing = new FakeIntegration(
+ PID + 1, root -> Map.of("status", "failed", "exception",
Map.of("message", error)))) {
+ ToolContext ctx = new ToolContext();
+ ctx.selectProcess(PID + 1);
+
+ JsonObject result = ExpressionEvaluator.evaluate(ctx, "groovy",
"body.find { it.sku == 'ABC' }",
+ "[{\"sku\": \"ABC\", \"qty\": 10}]");
+ assertThat(result.getString("status")).isEqualTo("ok");
+ assertThat(result.getString("result")).isEqualTo("{sku=ABC,
qty=10}");
+ assertThat(result.getString("evaluatedIn"))
+ .isEqualTo("a local scratch context, with the body parsed
from its JSON text");
+ assertThat(result.getString("note")).endsWith("; on the text
itself it fails: " + error);
+ // a body that is no JSON: the error of the integration
+ assertThatThrownBy(() -> ExpressionEvaluator.evaluate(ctx,
"groovy", "body.find { it.sku == 'ABC' }", "ABC"))
+
.isInstanceOf(ToolExecutionException.class).hasMessage(error);
+ }
+ }
+
private static JsonObject keyValue(String key, Object value) {
JsonObject jo = new JsonObject();
jo.put("key", key);
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorLanguageTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorLanguageTest.java
index f2c851c514e7..3997849cf20c 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorLanguageTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorLanguageTest.java
@@ -93,6 +93,44 @@ class ExpressionEvaluatorLanguageTest {
assertThat(result.getString("syntaxError")).isNotBlank();
}
+ /**
+ * r28 connect-stock-api step 2: field reads on a body passed as JSON text
failed, with the advice to unmarshal it
+ * first, which the tool cannot do (21 calls, no write). They are read on
the data a route has after unmarshal:
+ * json, with a note that says so.
+ */
+ @Test
+ void theFieldsOfAJsonTextBodyAreReadOnTheParsedData() {
+ JsonObject one = ExpressionEvaluator.evaluate(new ToolContext(),
"groovy", "body.find { it.sku == 'ABC' }",
+ "[{\"sku\": \"ABC\", \"qty\": 10}, {\"sku\": \"DEF\", \"qty\":
20}]");
+ assertThat(one.getString("status")).isEqualTo("ok");
+ assertThat(one.getString("result")).isEqualTo("{sku=ABC, qty=10}");
+ assertThat(one.getString("evaluatedIn")).isEqualTo("a local scratch
context, with the body parsed from its JSON text");
+ assertThat(one.getString("note")).startsWith("the body is JSON text,
so this is the result on that text parsed into"
+ + " a List, as a route
has the body after unmarshal: json, or in a split"
+ + " over that data; on
the text itself it fails: No such property: sku"
+ + " for class:
java.lang.String");
+ // r28l-7 connect-http-client step 1: one line of an order, read with
simple
+ JsonObject sku = ExpressionEvaluator.evaluate(new ToolContext(),
"simple", "${body[sku]}",
+ "{\"sku\": \"CAMEL-MUG\", \"qty\": 1, \"price\": 9.50}");
+ assertThat(sku.getString("status")).isEqualTo("ok");
+ assertThat(sku.getString("result")).isEqualTo("CAMEL-MUG");
+ assertThat(sku.getString("note")).contains("parsed into a Map");
+ // the numbers as unmarshal: json gives them to a route
+ assertThat(ExpressionEvaluator.evaluate(new ToolContext(), "simple",
"${body[price]}",
+ "{\"sku\": \"CAMEL-MUG\", \"qty\": 1, \"price\":
9.50}").getString("result")).isEqualTo("9.5");
+ // what works on the text is answered as before, and so is a failure
on text that is no JSON
+ JsonObject length = ExpressionEvaluator.evaluate(new ToolContext(),
"simple", "${body.length()}",
+ "{\"sku\": \"A\"}");
+ assertThat(length.getString("result")).isEqualTo("12");
+ assertThat(length.get("note")).isNull();
+ assertThat(ExpressionEvaluator.evaluate(new ToolContext(), "simple",
"${body[sku]}", "sku=A").getString("status"))
+ .isEqualTo("error");
+ // an error the parsed data does not cure stays the error on the text
+ JsonObject broken = ExpressionEvaluator.evaluate(new ToolContext(),
"simple", "${body[sku]", "{\"sku\": \"A\"}");
+ assertThat(broken.getString("status")).isEqualTo("error");
+ assertThat(broken.get("note")).isNull();
+ }
+
@Test
@EnabledIfSystemProperty(named = "camel.test.download", matches = "true")
void jsonpathIsDownloadedAndEvaluated() {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecksTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecksTest.java
index a807583054dc..45e08aa530e4 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecksTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/JsonPathChecksTest.java
@@ -246,4 +246,80 @@ class JsonPathChecksTest {
""";
assertThat(JsonPathChecks.validateYamlJsonPath(yaml,
catalog)).isEmpty();
}
+
+ /**
+ * r28n-3 contracts-rest-code-first step 1 [19] (shortened): [] after the
filter does not compile, which only the
+ * runtime said, 16 times ("Could not parse token starting at position
29").
+ */
+ @Test
+ void anExpressionThatDoesNotCompile() {
+ String yaml = """
+ - route:
+ id: get-order-by-id
+ from:
+ uri: direct:get-order-by-id
+ steps:
+ - setBody:
+ expression:
+ jsonpath:
+ expression: "$[?(@.orderId ==
'${header.id}')][]"
+ resultType: java.util.List
+ """;
+ assertThat(JsonPathChecks.validateYamlJsonPath(yaml,
catalog)).singleElement().isEqualTo(
+ "Line 9: jsonpath $[?(@.orderId == '${header.id}')][] does not
compile: Could not parse token starting at"
+
+ " position 33 (at []). Expected ?, ', 0-9, *; JsonPath has
no [], and a filter"
+
+ " or [*] gives a list already: write $[?(@.orderId ==
'${header.id}')]");
+ // without a placeholder, and in a split
+
assertThat(JsonPathChecks.validateYamlJsonPath(yaml.replace("$[?(@.orderId ==
'${header.id}')][]", "$.lines[]"),
+ catalog)).singleElement().asString()
+ .startsWith("Line 9: jsonpath $.lines[] does not compile:
Could not parse token starting at position 7"
+ + " (at [])")
+ .endsWith("write $.lines");
+ assertThat(JsonPathChecks.validateYamlJsonPath("""
+ - route:
+ from:
+ uri: timer:t
+ steps:
+ - split:
+ jsonpath: "$.lines[*]."
+ steps:
+ - log: "${body}"
+ """, catalog)).singleElement().isEqualTo(
+ "Line 6: jsonpath $.lines[*]. does not compile: Path must not
end with a '.' or '..'");
+ }
+
+ /** Camel fills in the ${...} first: a placeholder can be an index, a
number, a text or a name. */
+ @Test
+ void placeholdersAreFilledInFirst() {
+ String yaml = """
+ - route:
+ from:
+ uri: direct:stock
+ steps:
+ - setBody:
+ expression:
+ jsonpath:
+ expression: "%s"
+ """;
+ for (String path : List.of("$.items[${header.index}]", "$[?(@.qty >
${header.min})]",
+ "$[?(@.sku == '${header.sku}')].qty", "$.${header.field}",
"$.{{stock.field}}", "$[?(${header.filter})]",
+ "$..[?(@.sku in ['${header.a}', '${header.b}'])]",
"$.lines.length()")) {
+
assertThat(JsonPathChecks.validateYamlJsonPath(yaml.formatted(path),
catalog)).as(path).isEmpty();
+ }
+ // r28n-4: the two characters \n after the closing quote, a line with
no value to compile, which other checks say
+ assertThat(JsonPathChecks.compileProblem(catalog,
+ "\"$[?(@.orderId == '${header.id}')]\"\\n resultType:
java.util.List", false)).isNull();
+ // the easy predicate syntax of a when or filter is turned into a
filter first, so it is not compiled as written
+ assertThat(JsonPathChecks.validateYamlJsonPath("""
+ - route:
+ from:
+ uri: direct:stock
+ steps:
+ - filter:
+ expression:
+ jsonpath: "$.qty > ${header.min}"
+ steps:
+ - log: "low"
+ """, catalog)).isEmpty();
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
index c36c47269446..6fb0029a7e71 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
@@ -54,6 +54,37 @@ class RepeatedToolCallsTest {
assertThat(calls.repeatOf(catalogDoc,
sql).getInteger("timesAsked")).isEqualTo(4);
}
+ /**
+ * r28 connect-stock-api step 2: body.getClass().getName() evaluated 9
times with the same arguments, up to the call
+ * limit. The same expression on the same message gets the note from the
third call, unless the expression reads
+ * what changes from one call to the next.
+ */
+ @Test
+ void anIdenticalEvaluationGetsTheNoteUnlessItsAnswerCanChange() {
+ ToolDescriptor eval = ToolRegistry.findTool("camel_eval_expression");
+ Map<String, String> args = Map.of("expression",
"body.getClass().getName()", "language", "groovy", "body",
+ "[{\"sku\": \"ABC\", \"qty\": 10}]");
+ successfulCall(eval, args);
+ successfulCall(eval, args);
+ JsonObject note = calls.repeatOf(eval, args);
+ assertThat(note).isNotNull();
+ assertThat(note.getString("note")).startsWith("camel_eval_expression
was already called with these same arguments 2"
+ + " times")
+ .endsWith("The same expression on the same message gives the
same result: change the expression or the"
+ + " message (body, headers, variables) to learn
something new.");
+ // another message is another question
+ assertThat(calls.repeatOf(eval, Map.of("expression",
"body.getClass().getName()", "language", "groovy", "body",
+ "[]"))).isNull();
+ // a random value, the clock, an id, a property: asked again, the
answer can change
+ for (String expression : List.of("${random(1,10)}",
"${date:now:yyyy-MM-dd}", "${exchangeId}",
+ "${properties:stock.url}", "{{stock.url}}")) {
+ Map<String, String> changing = Map.of("expression", expression);
+ successfulCall(eval, changing);
+ successfulCall(eval, changing);
+ assertThat(calls.repeatOf(eval, changing)).as(expression).isNull();
+ }
+ }
+
/** CAMEL-25640: a call that throws must not consume the retry budget. */
@Test
void aFailedCallDoesNotCountAgainstTheRetryBudget() {
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 7874563574de..c74a2fcc259b 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
@@ -395,6 +395,7 @@ public class AuthoringTools {
+ "the value (true/false for a predicate) or the
syntax error, so check an expression before "
+ "answering or writing it.")
public JsonObject camel_eval_expression(
+ McpConnection connection,
@ToolArg(description = "e.g. ${random(1,10)} or ${body} ?:
'none'", required = true) String expression,
@ToolArg(description = "simple (default), jsonpath, xpath, jq",
required = false) String language,
@ToolArg(description = "Message body", required = false) String
body,
@@ -408,8 +409,9 @@ public class AuthoringTools {
required = false) Boolean predicate,
@ToolArg(description =
org.apache.camel.dsl.jbang.core.commands.ai.AuthoringTools.EVAL_RESULT_TYPE_DESC,
required = false) String resultType) {
- return call("camel_eval_expression", args("expression", expression,
"language", language, "body", body,
- "name", name, "headers", limited("headers",
jsonText(headers)), "variables",
+ // the same expression on the same message gets the same answer: the
third identical call gets a short note
+ return callDeterministic(connection, "camel_eval_expression",
args("expression", expression, "language",
+ language, "body", body, "name", name, "headers",
limited("headers", jsonText(headers)), "variables",
limited("variables", jsonText(variables)), "predicate",
predicate, "resultType", resultType));
}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
index 2ee5d19ac70b..7873744e3103 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
@@ -160,7 +160,8 @@ class AuthoringToolsTest {
CommandLineHelper.useHomeDir(home.toString());
try {
assertThatThrownBy(
- () -> tools.camel_eval_expression("${body}", null,
"camel", "no-such-app-xyz-1", null, null, null, null))
+ () -> tools.camel_eval_expression(null, "${body}", null,
"camel", "no-such-app-xyz-1", null, null, null,
+ null))
.isInstanceOf(ToolCallException.class).hasMessageContaining("no-such-app-xyz-1");
assertThatThrownBy(() -> tools.camel_get_log("no-such-app-xyz-1",
null, null, null, null, null))
.isInstanceOf(ToolCallException.class).hasMessageContaining("no-such-app-xyz-1");
@@ -179,27 +180,27 @@ class AuthoringToolsTest {
Path previousHome = CommandLineHelper.getHomeDir();
CommandLineHelper.useHomeDir(home.toString());
try {
- JsonObject values =
tools.camel_eval_expression("${header.foo}-${variable.bar}", null, null, null,
+ JsonObject values = tools.camel_eval_expression(null,
"${header.foo}-${variable.bar}", null, null, null,
Map.of("foo", "x"), "bar=y", null, null);
assertThat(values.getString("status")).isEqualTo("ok");
assertThat(values.getString("result")).isEqualTo("x-y");
- JsonObject typed = tools.camel_eval_expression("${body}", null,
"0042", null, null, null, null,
+ JsonObject typed = tools.camel_eval_expression(null, "${body}",
null, "0042", null, null, null, null,
"java.lang.Integer");
assertThat(typed.getString("result")).isEqualTo("42");
assertThat(typed.getString("resultType")).isEqualTo("java.lang.Integer");
- JsonObject predicate = tools.camel_eval_expression("true",
"constant", null, null, null, null, true, null);
+ JsonObject predicate = tools.camel_eval_expression(null, "true",
"constant", null, null, null, null, true, null);
assertThat(predicate.getBoolean("predicate")).isTrue();
assertThat(predicate.getString("result")).isEqualTo("true");
- assertThatThrownBy(() ->
tools.camel_eval_expression("${header.foo}", null, null, null, "foo", null,
null,
+ assertThatThrownBy(() -> tools.camel_eval_expression(null,
"${header.foo}", null, null, null, "foo", null, null,
null)).isInstanceOf(ToolCallException.class).hasMessageContaining("key=value");
// a JSON object is not a string argument, which the security
interceptor checks: its JSON is held to the
// same length limit
Map<String, Object> large = Map.of("foo",
"x".repeat(McpSecurityConfig.DEFAULT_MAX_ARGUMENT_LENGTH));
- assertThatThrownBy(() ->
tools.camel_eval_expression("${header.foo}", null, null, null, large, null,
null,
+ assertThatThrownBy(() -> tools.camel_eval_expression(null,
"${header.foo}", null, null, null, large, null, null,
null)).isInstanceOf(ToolCallException.class)
.hasMessageStartingWith("Argument 'headers' is ")
.hasMessageContaining(" characters long as JSON, over the
limit of "
@@ -253,10 +254,11 @@ class AuthoringToolsTest {
boolean marked = meta != null &&
"camel.apache.org/".equals(meta.prefix())
&& "deterministic".equals(meta.name()) &&
"true".equals(meta.value());
assertThat(marked).as(m.getName() + " marked
deterministic").isEqualTo(td.isDeterministic());
- // a tool that is deterministic for some arguments
(camel_validate_source with content) counts too
+ // a tool that is deterministic for some arguments
(camel_validate_source with content, an evaluation)
+ // counts too
boolean connected =
Arrays.stream(m.getParameterTypes()).anyMatch(t -> t == McpConnection.class);
assertThat(connected).as(m.getName() + " gets the connection
to count its repeats")
- .isEqualTo(td.isDeterministic() ||
td.deterministicWhen() != null);
+ .isEqualTo(td.isDeterministicForSomeCalls());
}
}
}
@@ -306,6 +308,34 @@ class AuthoringToolsTest {
.get("repeated")).isNull();
}
+ /** r28 connect-stock-api step 2: one evaluation 9 times with the same
arguments, up to the call limit. */
+ @Test
+ void aThirdIdenticalEvaluationOfAConnectionGetsAShortNote(@TempDir Path
home) {
+ // a home of its own: without a name the tool picks the only running
integration of the machine
+ Path previousHome = CommandLineHelper.getHomeDir();
+ CommandLineHelper.useHomeDir(home.toString());
+ try {
+ AuthoringTools counted = new AuthoringTools();
+ counted.repeatedCalls = new RepeatedCallSessions();
+ McpConnection agent = connection("agent-1");
+ for (int i = 0; i < 2; i++) {
+ assertThat(counted.camel_eval_expression(agent,
"${body.length()}", null, "[1, 2]", null, null, null, null,
+ null).getString("result")).isEqualTo("6");
+ }
+ JsonObject third = counted.camel_eval_expression(agent,
"${body.length()}", null, "[1, 2]", null, null, null,
+ null, null);
+ assertThat(third.getBoolean("repeated")).isTrue();
+
assertThat(third.getString("note")).contains("camel_eval_expression", "change
the expression or the message");
+ // a random value is asked again for a new one
+ for (int i = 0; i < 3; i++) {
+ assertThat(counted.camel_eval_expression(agent,
"${random(1,10)}", null, null, null, null, null, null, null)
+ .get("repeated")).isNull();
+ }
+ } finally {
+ CommandLineHelper.useHomeDir(previousHome.toString());
+ }
+ }
+
private static McpConnection connection(String id) {
return (McpConnection)
Proxy.newProxyInstance(McpConnection.class.getClassLoader(),
new Class<?>[] { McpConnection.class }, (proxy, method, args)
-> "id".equals(method.getName()) ? id : null);