You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/06 10:52:45 UTC
(camel) branch main updated: CAMEL-25371: camel-jbang-mcp - validating the same content again is a repeated call (#27422)
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 b5e138fc0562 CAMEL-25371: camel-jbang-mcp - validating the same content again is a repeated call (#27422)
b5e138fc0562 is described below
commit b5e138fc0562c69797eb4f4d4581658fcc0a502a
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Tue Oct 6 12:52:23 2026 +0200
CAMEL-25371: camel-jbang-mcp - validating the same content again is a repeated call (#27422)
* CAMEL-25371: camel-jbang-mcp - validating the same content again is a repeated call, and validating the file as it is on disk says so
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25371: address review feedback
- camel mcp counts camel_validate_source repeats too: the tool gets the connection and goes
through callDeterministic, as the catalog tools do
- validating content is only a repeat without a directory: with one, the checks read the other
files of the project, which a fix can change
- the content is keyed by a SHA-256 of its exact text, as whitespace can decide the answer (a
newline before <?xml ...?>), and the key no longer holds the source
- the sameAsFile note is conditional: if you meant to change the file, that change is not in
this content
- imports instead of fully qualified names
- docs: the Repeated calls section and the camel_validate_source row
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
---------
Signed-off-by: Claus Ibsen <cl...@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <no...@anthropic.com>
---
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 8 +++-
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 26 ++++++++++-
.../jbang/core/commands/ai/RepeatedToolCalls.java | 37 ++++++++++++---
.../dsl/jbang/core/commands/ai/ToolDescriptor.java | 53 ++++++++++++++++++++++
.../jbang/core/commands/ai/AuthoringToolsTest.java | 15 ++++++
.../core/commands/ai/RepeatedToolCallsTest.java | 48 ++++++++++++++++++++
.../jbang/core/commands/mcp/AuthoringTools.java | 7 ++-
.../core/commands/mcp/AuthoringToolsTest.java | 26 ++++++++++-
8 files changed, 208 insertions(+), 12 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index 5e23bf07a3e0..6884bc17ac33 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -324,7 +324,8 @@ project `directory` as an argument, the runtime tools take the integration `name
misspelled option such as `logLevel` instead of `loggingLevel`), endpoint URIs, simple expressions, and
`camel.*` options. The routes of a Java or XML file are read into the Camel model without compiling them and
get the same checks (the endpoint DSL and constants resolved, a `to` that needs `toD`); what the parser could
- not read is listed under `notChecked`.
+ not read is listed under `notChecked`. When the `content` is the same as the file on disk, the answer has
+ `sameAsFile: true` and a `note`: an edit the agent meant to make may not be in what it validated.
Takes the content, or reads the file from the project directory. With `camelVersion` (or the version of the
selected integration in the TUI) the catalog and the YAML DSL schema of that Camel version answer, the schema
read from its `camel-yaml-dsl` jar; without it the CLI's own, with nothing to download.
@@ -426,6 +427,11 @@ An agent asking one of them the same question again and again gets nothing new,
stuck doing that until its tool call budget is spent. So the first two identical calls of a session are answered
in full, and from the third the answer is a short note instead: that the call was already answered and how many
times it was asked. The changed answer helps the agent break the loop, and the context is not filled with copies.
+`camel_validate_source` counts the same way when it is given `content` and no `directory`: the same source gets
+the same answer, and the note also says to change the line the error names. Validating a file on disk, or
+`content` with a `directory` (the checks then read the other files of the project, which a fix can change), is
+always answered in full.
+
Calls with other arguments, and the tools that read files, logs or the running integration, are always answered
in full, so re-reading a file after an edit or polling a log keeps working.
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 2bf98df844f5..4bce96d071f9 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
@@ -162,6 +162,11 @@ public final class AuthoringTools {
.param("content", "string", "The source to validate", false)
.param("camelVersion", "string", VERSION_DESC, false)
.core(true)
+ // the same content gets the same answer: a model that validates it again and again is stuck
+ // (CAMEL-25371). Not with a directory: the checks then read the other files, which a fix can change.
+ .deterministicWhen("content", "directory")
+ .repeatHint("The content is the same each time, so the answer is too: change the line the error names,"
+ + " then validate the changed content, or write it with camel_write_file.")
.executor((ctx, args) -> {
applyVersion(ctx, args);
String file = required(args, "file");
@@ -176,8 +181,16 @@ public final class AuthoringTools {
throw new ToolExecutionException("No such file in the directory: " + file);
}
content = read(path);
+ return validate(ctx, dir, file, content).toJson();
}
- return validate(ctx, dir, file, content).toJson();
+ JsonObject result = validate(ctx, dir, file, content);
+ if (sameAsOnDisk(ctx, directory, file, content)) {
+ // a model that meant to change the file validates the old version: say so (CAMEL-25371)
+ result.put("sameAsFile", true);
+ result.put("note", "This content is the same as " + file + " on disk: if you meant to change"
+ + " the file, that change is not in this content.");
+ }
+ return result.toJson();
}));
registry.accept(tool("camel_get_files",
@@ -1391,4 +1404,15 @@ public final class AuthoringTools {
}
return errors;
}
+
+ /** Whether the content is exactly the file's content on disk; false when there is no such file. */
+ static boolean sameAsOnDisk(ToolContext ctx, String directory, String file, String content) {
+ try {
+ Path path = resolveFile(ctx.resolveDirectory(directory), file);
+ return Files.isRegularFile(path) && read(path).strip().equals(content.strip());
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
index 1ea8e2475d75..a02bd9776d4d 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
@@ -16,7 +16,11 @@
*/
package org.apache.camel.dsl.jbang.core.commands.ai;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
+import java.util.HexFormat;
import java.util.Map;
import java.util.TreeMap;
@@ -51,13 +55,13 @@ public final class RepeatedToolCalls {
* identical calls.
*/
public synchronized JsonObject repeatOf(ToolDescriptor tool, Map<String, ?> args) {
- if (tool == null || !tool.isDeterministic()) {
+ if (tool == null || !tool.isDeterministic(args)) {
return null;
}
if (counts.size() >= MAX_TRACKED) {
counts.clear();
}
- int times = counts.merge(key(tool.name(), args), 1, Integer::sum);
+ int times = counts.merge(key(tool.name(), args, tool.deterministicWhen()), 1, Integer::sum);
if (times <= FULL_ANSWERS) {
return null;
}
@@ -67,7 +71,8 @@ public final class RepeatedToolCalls {
note.put("timesAsked", times);
note.put("note", tool.name() + " was already called with these same arguments " + (times - 1)
+ " times in this session and answered in full. Its answer does not change, so it is not sent"
- + " again: it is in your earlier tool result.");
+ + " again: it is in your earlier tool result."
+ + (tool.repeatHint() != null ? " " + tool.repeatHint() : ""));
return note;
}
@@ -81,16 +86,36 @@ public final class RepeatedToolCalls {
* argument as an empty string is asking the same question as one that leaves it out.
*/
static String key(String tool, Map<String, ?> args) {
+ return key(tool, args, null);
+ }
+
+ /**
+ * As {@link #key(String, Map)}, with the argument that makes the call deterministic (the source to validate) keyed
+ * by a digest of its exact text: whitespace can decide the answer there (a newline before {@code <?xml ...?>}), and
+ * the key does not hold a copy of the source.
+ */
+ static String key(String tool, Map<String, ?> args, String exact) {
Map<String, String> sorted = new TreeMap<>();
if (args != null) {
for (Map.Entry<String, ?> e : args.entrySet()) {
Object v = e.getValue();
- String s = v != null ? v.toString().trim() : "";
- if (!s.isEmpty()) {
- sorted.put(e.getKey(), s);
+ String s = v != null ? v.toString() : "";
+ if (s.isBlank()) {
+ continue;
}
+ sorted.put(e.getKey(), e.getKey().equals(exact) ? digest(s) : s.trim());
}
}
return tool + sorted;
}
+
+ private static String digest(String text) {
+ try {
+ byte[] hash = MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(hash);
+ } catch (NoSuchAlgorithmException e) {
+ // every JVM has SHA-256; the text itself is an exact key too
+ return text;
+ }
+ }
}
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 a388a1180c49..08b7881473ff 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
@@ -38,6 +38,9 @@ public class ToolDescriptor {
private boolean destructive = false;
private boolean core = false;
private boolean deterministic = false;
+ private String deterministicWhen;
+ private String[] deterministicUnless = new String[0];
+ private String repeatHint;
public record Param(String name, String type, String description, boolean required) {
}
@@ -93,6 +96,23 @@ public class ToolDescriptor {
return this;
}
+ /**
+ * Marks the tool as deterministic when the given argument is passed and none of the unless arguments is: validating
+ * the given content always gives the same answer, while validating the file on disk, or content checked against the
+ * other files of a directory, does not (CAMEL-25371).
+ */
+ public ToolDescriptor deterministicWhen(String param, String... unless) {
+ deterministicWhen = param;
+ deterministicUnless = unless != null ? unless : new String[0];
+ 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;
+ return this;
+ }
+
public ToolDescriptor executor(ToolExecutor exec) {
this.executor = exec;
return this;
@@ -128,6 +148,39 @@ public class ToolDescriptor {
return deterministic;
}
+ /**
+ * Whether this call gives the same answer each time: the tool is deterministic, or the argument that makes it so is
+ * passed without the ones that make it read other files.
+ */
+ public boolean isDeterministic(Map<String, ?> args) {
+ if (deterministic) {
+ return true;
+ }
+ if (deterministicWhen == null || !hasValue(args, deterministicWhen)) {
+ return false;
+ }
+ for (String unless : deterministicUnless) {
+ if (hasValue(args, unless)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** The argument that makes a call deterministic, or null when the tool is always or never deterministic. */
+ public String deterministicWhen() {
+ return deterministicWhen;
+ }
+
+ private static boolean hasValue(Map<String, ?> args, String name) {
+ Object v = args != null ? args.get(name) : null;
+ return v != null && !v.toString().isBlank();
+ }
+
+ public String repeatHint() {
+ return repeatHint;
+ }
+
/**
* The JSON schema of the tool's arguments, the way an MCP server lists it under {@code inputSchema} and an LLM
* client sends it as the function parameters: an object with one property per parameter (its type and description)
diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
index 4ab4d5def8a8..60d970a98157 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
@@ -367,4 +367,19 @@ class AuthoringToolsTest {
.get("errors")).get(0)).get("message")).get("body");
assertEquals("{\"orderId\":\"ORD-1003\"}", body.getString("value"));
}
+
+ /** CAMEL-25371: validating content that is the file on disk says the edit is not in it. */
+ @Test
+ void validatingTheFileAsItIsSaysSo(@TempDir Path dir) throws Exception {
+ Files.writeString(dir.resolve("route.camel.yaml"), VALID_ROUTE);
+ JsonObject same = call("camel_validate_source", new ToolContext(),
+ Map.of("directory", dir.toString(), "file", "route.camel.yaml", "content", VALID_ROUTE));
+ assertTrue(Boolean.TRUE.equals(same.get("sameAsFile")), same.toJson());
+ assertTrue(same.getString("note").contains("if you meant to change the file, that change is not in this content"),
+ same.toJson());
+
+ JsonObject changed = call("camel_validate_source", new ToolContext(),
+ Map.of("directory", dir.toString(), "file", "route.camel.yaml", "content", INVALID_ROUTE));
+ assertFalse(changed.containsKey("sameAsFile"), changed.toJson());
+ }
}
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 3883b3380de2..82ea1a45aab0 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
@@ -87,4 +87,52 @@ class RepeatedToolCallsTest {
calls.reset();
assertThat(calls.repeatOf(catalogDoc, sql)).isNull();
}
+
+ /** CAMEL-25371: validating the same content is the same question; validating the file on disk is not. */
+ @Test
+ void validatingTheSameContentIsARepeat() {
+ ToolDescriptor validate = ToolRegistry.findTool("camel_validate_source");
+ Map<String, String> args = Map.of("file", "route.camel.yaml", "content", "- from:\n uri: timer:x\n");
+ assertThat(calls.repeatOf(validate, args)).isNull();
+ assertThat(calls.repeatOf(validate, args)).isNull();
+ JsonObject note = calls.repeatOf(validate, args);
+ assertThat(note).isNotNull();
+ assertThat(note.getString("note")).contains("change the line the error names");
+
+ Map<String, String> fromDisk = Map.of("file", "route.camel.yaml");
+ for (int i = 0; i < 4; i++) {
+ assertThat(calls.repeatOf(validate, fromDisk)).isNull();
+ }
+ }
+
+ /**
+ * CAMEL-25371: with a directory the checks read the other files of the project, so after a fix in another file the
+ * same content gets another answer: always answered in full.
+ */
+ @Test
+ void validatingContentAgainstADirectoryIsNotARepeat() {
+ ToolDescriptor validate = ToolRegistry.findTool("camel_validate_source");
+ Map<String, String> args = Map.of("directory", "/work/project", "file", "orders.camel.yaml",
+ "content", "- from:\n uri: timer:x\n steps:\n - to: direct:audit\n");
+ for (int i = 0; i < 4; i++) {
+ assertThat(calls.repeatOf(validate, args)).isNull();
+ }
+ }
+
+ /**
+ * CAMEL-25371: the source is compared exactly, as whitespace can decide the answer (a newline before
+ * {@code <?xml}).
+ */
+ @Test
+ void contentThatOnlyDiffersInWhitespaceIsAnotherQuestion() {
+ ToolDescriptor validate = ToolRegistry.findTool("camel_validate_source");
+ String xml = "<?xml version=\"1.0\"?>\n<routes/>\n";
+ Map<String, String> withNewline = Map.of("file", "routes.camel.xml", "content", "\n" + xml);
+ assertThat(calls.repeatOf(validate, withNewline)).isNull();
+ assertThat(calls.repeatOf(validate, withNewline)).isNull();
+ // the fix (the newline removed) is validated, not answered with the note
+ assertThat(calls.repeatOf(validate, Map.of("file", "routes.camel.xml", "content", xml))).isNull();
+ // and the key does not hold the source itself
+ assertThat(RepeatedToolCalls.key("camel_validate_source", withNewline, "content")).doesNotContain("<routes/>");
+ }
}
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 afd3fdb1a9d2..a4b20b6be28a 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
@@ -130,13 +130,16 @@ public class AuthoringTools {
+ "its builder() accepts, the ways to create a class with no constructor). Use on content "
+ "before writing it, or on an existing file (no content) to explain a reload error.")
public JsonObject camel_validate_source(
+ McpConnection connection,
@ToolArg(description = DIRECTORY_DESC + "; needed when no content is given", required = false) String directory,
@ToolArg(description = "File name; picks the checks by extension, read when no content",
required = true) String file,
@ToolArg(description = "The source to validate", required = false) String content,
@ToolArg(description = VERSION_DESC, required = false) String camelVersion) {
- return call("camel_validate_source", args("directory", directory, "file", file, "content", content,
- "camelVersion", camelVersion));
+ // the same content without a directory gets the same answer: the third identical call gets a short note
+ // (CAMEL-25371)
+ return callDeterministic(connection, "camel_validate_source", args("directory", directory, "file", file,
+ "content", content, "camelVersion", camelVersion));
}
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false),
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 6ddf954e7b9c..a552525e3b22 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
@@ -103,7 +103,7 @@ class AuthoringToolsTest {
JsonObject listed = tools.camel_get_files(dir.toString(), null);
assertThat(listed.getInteger("totalFiles")).isEqualTo(1);
assertThat(tools.camel_get_files(dir.toString(), "demo.camel.yaml").getString("content")).isEqualTo(route);
- assertThat(tools.camel_validate_source(dir.toString(), "demo.camel.yaml", null, null).getBoolean("valid"))
+ assertThat(tools.camel_validate_source(null, dir.toString(), "demo.camel.yaml", null, null).getBoolean("valid"))
.isTrue();
assertThatThrownBy(() -> tools.camel_get_files(null, null))
.isInstanceOf(ToolCallException.class).hasMessageContaining("directory is required");
@@ -152,9 +152,10 @@ 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
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());
+ .isEqualTo(td.isDeterministic() || td.deterministicWhen() != null);
}
}
}
@@ -183,6 +184,27 @@ class AuthoringToolsTest {
.get("repeated")).isNull();
}
+ /** CAMEL-25371: validating the same content again is a repeat, also over the camel mcp server. */
+ @Test
+ void aThirdIdenticalValidationOfTheSameContentGetsAShortNote() {
+ AuthoringTools counted = new AuthoringTools();
+ counted.repeatedCalls = new RepeatedCallSessions();
+ McpConnection agent = connection("agent-1");
+ String content = "- from:\n uri: timer:x\n steps:\n - to: log:x\n";
+
+ JsonObject first = counted.camel_validate_source(agent, null, "route.camel.yaml", content, null);
+ JsonObject second = counted.camel_validate_source(agent, null, "route.camel.yaml", content, null);
+ JsonObject third = counted.camel_validate_source(agent, null, "route.camel.yaml", content, null);
+
+ assertThat(first.get("repeated")).isNull();
+ assertThat(second.get("repeated")).isNull();
+ assertThat(third.getBoolean("repeated")).isTrue();
+ assertThat(third.getString("note")).contains("camel_validate_source", "change the line the error names");
+ // changed content is a new question
+ assertThat(counted.camel_validate_source(agent, null, "route.camel.yaml", content + "\n", null)
+ .get("repeated")).isNull();
+ }
+
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);