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);

Reply via email to