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 c42618353434 CAMEL-26411: camel-jbang - AI tools: 
camel_eval_expression names the missing body and the unset header or variable 
(#29738)
c42618353434 is described below

commit c42618353434307ce62eb315ce9326647c5a0e8e
Author: Federico Mariani <[email protected]>
AuthorDate: Thu Nov 12 21:46:50 2026 +0100

    CAMEL-26411: camel-jbang - AI tools: camel_eval_expression names the 
missing body and the unset header or variable (#29738)
    
    camel_eval_expression evaluates against a scratch exchange, and some of its 
answers sent local models the wrong way. In ExpressionEvaluator:
    
    - With no body the note says that the body is empty text, not null as in a 
route whose message has none.
    - jsonpath, jq, xpath and xquery with no body now say that the language 
reads its JSON or XML from the body, and to pass it as body.
    - A Simple expression that is null because it reads a header or variable 
that was not passed names it and the headers/variables argument (a name in 
brackets or quotes is the whole name). For CamelRedeliveryCounter, 
CamelRedeliveryMaxCounter and CamelRedelivered it explains when the error 
handler sets them. ${exchangeProperty.X} says that an evaluation has no 
exchange properties.
    
    Claude Code on behalf of Federico Mariani.
    
    Co-Authored-By: Claude <[email protected]>
---
 .../core/commands/ai/ExpressionEvaluator.java      | 103 ++++++++++++++++++---
 .../core/commands/ai/EvalInTheIntegrationTest.java |  22 ++++-
 .../commands/ai/ExpressionEvaluatorNotesTest.java  |  95 +++++++++++++++++++
 .../core/commands/ai/ExpressionEvaluatorTest.java  |   6 +-
 4 files changed, 212 insertions(+), 14 deletions(-)

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 ac2021ee1c3a..2b5eb48b47f8 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
@@ -174,8 +174,9 @@ public final class ExpressionEvaluator {
         } else if (resultType != null) {
             result.put("resultType", resultType);
         }
-        if (body == null && expression.contains("${body")) {
-            result.put("note", "evaluated with an empty body; pass body to 
evaluate against a value");
+        String noBody = noBodyNote(lang, expression, body);
+        if (noBody != null) {
+            result.put("note", noBody);
         }
         if (predicate && resultType != null) {
             addNote(result, "resultType is not used for a predicate, which 
gives true or false");
@@ -198,19 +199,96 @@ public final class ExpressionEvaluator {
         if ("error".equals(result.getString("status"))) {
             evaluateOnParsedBody(ctx, r, result, result.getString("error"));
         }
-        addNullAndElvisNotes(ctx, result, lang, expression);
+        addNullAndElvisNotes(ctx, result, r);
         addFilterNote(result, lang, expression);
         addXPathHint(result, lang, expression);
         return result;
     }
 
-    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";
+    static final String NULL_NOTE = "evaluated to null: a header, property or 
variable that is not set (an evaluation has"
+                                    + " only the headers and variables passed 
to it), or a function name that took in"
+                                    + " the rest of the text";
+
+    static final String NO_BODY_NOTE = "no body was passed, so the body is 
empty text (\"\"), not null as in a route"
+                                       + " whose message has none (after a 
timer, for example); pass body to evaluate"
+                                       + " against a value";
 
     /** 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", "spel", "js", "javascript",
             "python", "java", "joor", "jactl");
 
+    /** The languages that read their document from the body, and what that 
document is. */
+    private static final Map<String, String> BODY_DOCUMENT
+            = Map.of("jsonpath", "JSON", "jq", "JSON", "xpath", "XML", 
"xquery", "XML");
+
+    // a header, exchange property or variable that simple reads by name: 
${header.foo}, ${headers:foo},
+    // ${header[foo]}, ${headerAs(foo, Integer)}, ${exchangeProperty.foo}, 
${variable.foo}. A name in brackets or in
+    // quotes is the whole name, as for simple: ${header['kafka.KEY']} reads 
the header kafka.KEY, not kafka
+    private static final Pattern READS_BY_NAME = Pattern.compile(
+            
"\\$\\{\\s*(?:in\\.)?(header|exchangeProperty|variable)s?(?:[.:\\[]|As\\()\\s*['\"]?"
+                                                                 + 
"((?<=\\[)[^\\]'\"]+|(?<=['\"])[^'\"]+|[\\w-]+)");
+
+    /** The headers the error handler sets from the first redelivery on. */
+    private static final Set<String> REDELIVERY_HEADERS
+            = Set.of("CamelRedeliveryCounter", "CamelRedeliveryMaxCounter", 
"CamelRedelivered");
+
+    /**
+     * What to say when no body is passed and the expression reads it (r24): 
the body is then empty text, so ${body} ==
+     * null answered false, and jsonpath failed with "json string can not be 
null or empty", which does not name the
+     * body.
+     */
+    static String noBodyNote(String lang, String expression, String body) {
+        if (body != null || expression == null) {
+            return null;
+        }
+        String document = BODY_DOCUMENT.get(lang);
+        if (document != null) {
+            return "no body was passed: " + lang + " reads the " + document + 
" of the message body, so this was"
+                   + " evaluated on empty text; pass the " + document + " as 
body";
+        }
+        return expression.contains("${body") ? NO_BODY_NOTE : null;
+    }
+
+    /**
+     * Why a simple expression evaluated to null when it reads a header, an 
exchange property or a variable the
+     * evaluation does not have, else {@link #NULL_NOTE}. r24 to r26 
fail-well-error-handling step 2:
+     * ${header.CamelRedeliveryCounter} was null, and the model looked for it 
as an exchange property, or in 11 lookups
+     * of the Exchange API, and wrote nothing.
+     */
+    static String nullNote(
+            String lang, String expression, Map<String, Object> headers, 
Map<String, Object> variables) {
+        Matcher m = "simple".equals(lang) && expression != null ? 
READS_BY_NAME.matcher(expression) : null;
+        while (m != null && m.find()) {
+            String name = m.group(2).strip();
+            boolean redelivery = REDELIVERY_HEADERS.contains(name);
+            if ("exchangeProperty".equals(m.group(1))) {
+                return "evaluated to null: the exchange property " + name + " 
is not set: an evaluation has no exchange"
+                       + " properties" + (redelivery ? "; " + name + " is a 
header: ${header." + name + "}" : "");
+            }
+            if ("variable".equals(m.group(1))) {
+                // a scoped variable, such as ${variable.global:foo}, keeps 
the generic note
+                if (!expression.startsWith(":", m.end()) && (variables == null 
|| !variables.containsKey(name))) {
+                    return "evaluated to null: the variable " + name + " is 
not set: an evaluation has only the"
+                           + " variables passed in variables";
+                }
+                continue;
+            }
+            // the headers of a message do not tell case
+            if (headers == null || 
headers.keySet().stream().noneMatch(name::equalsIgnoreCase)) {
+                return "evaluated to null: the header " + name + " is not set: 
an evaluation has only the headers"
+                       + " passed in headers"
+                       + (redelivery
+                               ? "; the error handler sets it on the message 
it redelivers, from the first redelivery"
+                                 + " on (the redelivered step and onRedelivery 
see it), so it is not set on the first"
+                                 + " attempt; an onException that handles or 
continues the exchange, and a dead"
+                                 + " letter channel, do not see it, because 
the error handler removes it before they"
+                                 + " run"
+                               : "");
+            }
+        }
+        return NULL_NOTE;
+    }
+
     /**
      * 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.
@@ -318,14 +396,15 @@ public final class ExpressionEvaluator {
      * header named 'offset ?:0' (CAMEL-26401). An elvis operator inside 
${...} that the Camel of the evaluation does
      * not read as one is the note, also when the value is not null or the 
evaluation failed.
      */
-    static void addNullAndElvisNotes(ToolContext ctx, JsonObject result, 
String lang, String expression) {
+    static void addNullAndElvisNotes(ToolContext ctx, JsonObject result, 
Request r) {
         boolean isNull = "ok".equals(result.getString("status")) && 
result.get("result") == null;
         if (isNull) {
             result.put("result", null);
             result.put("nullResult", true);
         }
-        String elvis = elvisNote(ctx, lang, expression);
-        String note = elvis != null ? elvis : isNull ? NULL_NOTE : null;
+        String elvis = elvisNote(ctx, r.language(), r.expression());
+        String note
+                = elvis != null ? elvis : isNull ? nullNote(r.language(), 
r.expression(), r.headers(), r.variables()) : null;
         if (note != null) {
             result.put("note", result.containsKey("note") ? 
result.getString("note") + "; " + note : note);
         }
@@ -461,16 +540,18 @@ public final class ExpressionEvaluator {
         } catch (ToolExecutionException e) {
             // the integration answers an expression that fails as a failed 
action, a tool error (CAMEL-25398): what
             // to pass for an XPath function, that resultType was not used, 
and the rewrite of an elvis operator inside
-            // ${...} that an integration before Camel 4.24 refuses, go with 
its message
+            // ${...} that an integration before Camel 4.24 refuses, and that 
no body was passed, go with its message
             String hint = xpathHint(r.language(), r.expression(), 
e.getMessage());
             String elvis = elvisNote(ctx, r.language(), r.expression());
             String filter = filterNote(r.language(), r.expression(), "error");
-            if (hint == null && ignored == null && elvis == null && filter == 
null) {
+            String noBody = noBodyNote(r.language(), r.expression(), r.body());
+            if (hint == null && ignored == null && elvis == null && filter == 
null && noBody == null) {
                 throw e;
             }
             throw new ToolExecutionException(
                     e.getMessage() + (hint != null ? "; " + hint : "") + 
(ignored != null ? "; " + ignored : "")
-                                             + (elvis != null ? "; " + elvis : 
"") + (filter != null ? "; " + filter : ""));
+                                             + (elvis != null ? "; " + elvis : 
"") + (filter != null ? "; " + filter : "")
+                                             + (noBody != null ? "; " + noBody 
: ""));
         }
         JsonObject out = null;
         try {
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 eaf28a9bf9a2..fb1429b3c07b 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
@@ -99,7 +99,27 @@ class EvalInTheIntegrationTest {
         assertThat(result.getString("status")).isEqualTo("ok");
         assertThat(result.get("result")).isNull();
         assertThat(result.getBoolean("nullResult")).isTrue();
-        
assertThat(result.getString("note")).isEqualTo(ExpressionEvaluator.NULL_NOTE);
+        assertThat(result.getString("note")).startsWith("evaluated to null: 
the header missing is not set");
+    }
+
+    /**
+     * The integration evaluates with empty text as the body when none is 
passed, as the local context does: the error
+     * of a language that reads its document from the body names it 
(CAMEL-26411).
+     */
+    @Test
+    void aLanguageThatReadsTheBodyFailsWithTheNoteWhenNoneIsPassed() throws 
Exception {
+        try (FakeIntegration failing = new FakeIntegration(
+                PID + 1, root -> Map.of("status", "failed", "exception",
+                        Map.of("message", "java.lang.IllegalArgumentException: 
json string can not be null or empty")))) {
+            ToolContext ctx = new ToolContext();
+            ctx.selectProcess(PID + 1);
+
+            assertThatThrownBy(() -> ExpressionEvaluator.evaluate(ctx, 
"jsonpath", "$.lines", null))
+                    .isInstanceOf(ToolExecutionException.class)
+                    .hasMessage("java.lang.IllegalArgumentException: json 
string can not be null or empty; no body was"
+                                + " passed: jsonpath reads the JSON of the 
message body, so this was evaluated on empty"
+                                + " text; pass the JSON as body");
+        }
     }
 
     @Test
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorNotesTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorNotesTest.java
new file mode 100644
index 000000000000..bc069c48630e
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorNotesTest.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.ai;
+
+import java.util.Map;
+
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-26411: the answer of camel_eval_expression says what the evaluation 
did not have, so a model does not take it
+ * for what the route will have.
+ */
+class ExpressionEvaluatorNotesTest {
+
+    /** r24: jsonpath with no body answered "json string can not be null or 
empty", 3 times. */
+    @Test
+    void aLanguageThatReadsItsDocumentFromTheBodyNamesIt() {
+        JsonObject none = ExpressionEvaluator.evaluate(new ToolContext(), 
"xpath", "/order/id", null);
+        assertThat(none.getString("status")).isEqualTo("error");
+        assertThat(none.getString("note")).isEqualTo("no body was passed: 
xpath reads the XML of the message body, so"
+                                                     + " this was evaluated on 
empty text; pass the XML as body");
+
+        JsonObject body = ExpressionEvaluator.evaluate(new ToolContext(), 
"xpath", "/order/id", "<order><id>1</id></order>");
+        assertThat(body.get("note")).isNull();
+
+        assertThat(ExpressionEvaluator.noBodyNote("xquery", "/order/id", null))
+                .isEqualTo("no body was passed: xquery reads the XML of the 
message body, so this was evaluated on"
+                           + " empty text; pass the XML as body");
+    }
+
+    /**
+     * r24 to r26: ${header.CamelRedeliveryCounter} evaluated to null, then 
the model read it as an exchange property,
+     * or made 11 lookups of the Exchange API, and wrote nothing.
+     */
+    @Test
+    void anUnsetHeaderSaysHowToPassItAndWhoSetsIt() {
+        assertThat(ExpressionEvaluator.evaluate(new ToolContext(), "simple", 
"${header.CamelRedeliveryCounter}", null)
+                .getString("note")).isEqualTo(
+                        "evaluated to null: the header CamelRedeliveryCounter 
is not set: an evaluation has only the"
+                                              + " headers passed in headers; 
the error handler sets it on the message"
+                                              + " it redelivers, from the 
first redelivery on (the redelivered step and"
+                                              + " onRedelivery see it), so it 
is not set on the first attempt; an"
+                                              + " onException that handles or 
continues the exchange, and a dead"
+                                              + " letter channel, do not see 
it, because the error handler removes it"
+                                              + " before they run");
+        assertThat(ExpressionEvaluator.nullNote("simple", 
"${exchangeProperty.CamelRedeliveryCounter}", null, null))
+                .isEqualTo("evaluated to null: the exchange property 
CamelRedeliveryCounter is not set: an evaluation has"
+                           + " no exchange properties; CamelRedeliveryCounter 
is a header: ${header.CamelRedeliveryCounter}");
+        // a header that was passed: what is null is something else
+        assertThat(ExpressionEvaluator.nullNote("simple", 
"${header.order[missing]}", Map.of("order", Map.of()), null))
+                .isEqualTo(ExpressionEvaluator.NULL_NOTE);
+    }
+
+    /** A name in brackets is the whole name, as for simple: camel-kafka's 
headers have a dot in it. */
+    @Test
+    void aHeaderNameInBracketsIsTheWholeName() {
+        String note = "evaluated to null: the header kafka.KEY is not set: an 
evaluation has only the headers passed in"
+                      + " headers";
+        assertThat(ExpressionEvaluator.nullNote("simple", 
"${header['kafka.KEY']}", null, null)).isEqualTo(note);
+        assertThat(ExpressionEvaluator.nullNote("simple", 
"${header[kafka.KEY]}", null, null)).isEqualTo(note);
+        assertThat(ExpressionEvaluator.nullNote("simple", 
"${header['kafka.KEY']}", Map.of("kafka.KEY", ""), null))
+                .isEqualTo(ExpressionEvaluator.NULL_NOTE);
+    }
+
+    @Test
+    void anUnsetVariableSaysHowToPassIt() {
+        assertThat(ExpressionEvaluator.evaluate(new ToolContext(), "simple", 
"${variable.orderId}", null)
+                .getString("note"))
+                .isEqualTo("evaluated to null: the variable orderId is not 
set: an evaluation has only the variables"
+                           + " passed in variables");
+        // a variable that was passed: what is null is something else
+        assertThat(ExpressionEvaluator.nullNote("simple", 
"${variable.order[missing]}", null, Map.of("order", Map.of())))
+                .isEqualTo(ExpressionEvaluator.NULL_NOTE);
+        // a scoped variable keeps the generic note
+        assertThat(ExpressionEvaluator.nullNote("simple", 
"${variable.global:orderId}", null, null))
+                .isEqualTo(ExpressionEvaluator.NULL_NOTE);
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorTest.java
index 29d365bc7136..207f7fcf1a2c 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorTest.java
@@ -56,7 +56,7 @@ class ExpressionEvaluatorTest {
         assertEquals("true", predicate.getString("result"));
 
         JsonObject note = ExpressionEvaluator.evaluate(ctx, "simple", 
"${body.length()}", null);
-        assertTrue(note.getString("note").contains("empty body"));
+        assertEquals(ExpressionEvaluator.NO_BODY_NOTE, note.getString("note"));
 
         JsonObject error = ExpressionEvaluator.evaluate(ctx, "simple", 
"${header.foo ?: x}", null);
         assertEquals("error", error.getString("status"), "the fallback is not 
quoted");
@@ -85,7 +85,9 @@ class ExpressionEvaluatorTest {
         assertTrue(value.containsKey("result"));
         assertNull(value.get("result"));
         assertTrue(value.getBoolean("nullResult"));
-        assertEquals(ExpressionEvaluator.NULL_NOTE, value.getString("note"));
+        assertEquals("evaluated to null: the header missing is not set: an 
evaluation has only the headers passed in"
+                     + " headers",
+                value.getString("note"));
 
         JsonObject empty = ExpressionEvaluator.evaluate(new ToolContext(), 
"simple", "${body}", null);
         assertNull(empty.get("nullResult"), "an empty body is a value");

Reply via email to