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 <[email protected]>
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) <[email protected]>
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) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---------
Signed-off-by: Claus Ibsen <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../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);