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 047f560d04cf CAMEL-25556: camel-jbang-mcp - a write the same as the
last refused one says so, and the hint for an option at the column of its EIP
shows the lines to write (#27684)
047f560d04cf is described below
commit 047f560d04cfbc23f31fe5f4917764ecc4fa8e4c
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Oct 11 14:05:30 2026 +0200
CAMEL-25556: camel-jbang-mcp - a write the same as the last refused one
says so, and the hint for an option at the column of its EIP shows the lines to
write (#27684)
A local model that wrote steps: at the column of - split: sent the same
content sixteen times in a row after the hint of CAMEL-25529, until it ran out
of tool calls: it does not see the problem in its own lines, and copies lines
it is given.
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 29 +++++
.../dsl/jbang/core/commands/ai/IndentLines.java | 126 +++++++++++++++++++++
.../jbang/core/commands/ai/SourceValidator.java | 3 +
.../core/commands/ai/AuthoringToolsLoopTest.java | 117 +++++++++++++++++++
4 files changed, 275 insertions(+)
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 61e105595dc7..4d2be4bff8a0 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
@@ -35,6 +35,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -638,6 +639,10 @@ public final class AuthoringTools {
result.put("edits", edit.get("edits"));
result.put("editedAtLines", edit.get("editedAtLines"));
}
+ } else if (result.get("repeated") != null) {
+ result.put("message", "The file was not changed: the edit gives
the same content as your last attempt,"
+ + " refused " +
result.getInteger("repeated") + " times now for the same problems."
+ + " Change what the errors say; where an
error shows the lines to write, copy them.");
} else {
result.put("message", "The file was not changed: the result has
validation errors. Fix them and call"
+ " camel_edit_file again.");
@@ -1093,6 +1098,16 @@ public final class AuthoringTools {
result.put("errors", new JsonArray(errors));
result.put("message", "The file was not written: the content
has validation errors. Fix them and"
+ " call camel_write_file again.");
+ int times = refusedAgain(path, content);
+ if (times > 1) {
+ // the same content again: a model that does not see the
problem in its own lines writes them
+ // again and again, sixteen times in a row in the
benchmark; say so, and what to do instead
+ // (CAMEL-25556)
+ result.put("repeated", times);
+ result.put("message", "The file was not written: the
content is the same as your last write of"
+ + " it, refused " + times + " times
now for the same problems. Change what"
+ + " the errors say; where an error
shows the lines to write, copy them.");
+ }
putKameletGuide(result, file, errors);
return result;
}
@@ -1127,6 +1142,7 @@ public final class AuthoringTools {
} catch (IOException e) {
throw new ToolExecutionException("Failed to write " + path + ": "
+ e.getMessage());
}
+ LAST_REFUSED.remove(path.toAbsolutePath().normalize());
JsonObject result = new JsonObject();
result.put("status", exists ? "overwritten" : "created");
result.put("file", file);
@@ -1180,6 +1196,19 @@ public final class AuthoringTools {
return result;
}
+ /** The content of the last refused write of each file, and how many times
in a row it was refused. */
+ private record Refused(String content, int times) {
+ }
+
+ private static final Map<Path, Refused> LAST_REFUSED = new
ConcurrentHashMap<>();
+
+ /** How many times in a row this content of the file has been refused,
this time included. */
+ static int refusedAgain(Path path, String content) {
+ return LAST_REFUSED.merge(path.toAbsolutePath().normalize(), new
Refused(content, 1),
+ (last, now) -> last.content().equals(content) ? new
Refused(content, last.times() + 1) : now)
+ .times();
+ }
+
private static final Pattern UNCONSUMED_DIRECT = Pattern.compile("sends to
(direct:[^,\\s]+), and no route consumes it");
/**
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IndentLines.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IndentLines.java
new file mode 100644
index 000000000000..91ca88904931
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IndentLines.java
@@ -0,0 +1,126 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The lines to write for an option of a step's EIP written at the column of
the EIP (steps: next to - split:): the
+ * validator says which way to indent them (CAMEL-25529), and a model that
does not see it in its own lines writes the
+ * same YAML again (sixteen times in a row in the local-model benchmark); the
lines as they must be, it copies
+ * (CAMEL-25556).
+ */
+final class IndentLines {
+
+ /** The validator's message for it: "Line 13: must have at most 1
properties (steps: lines up with split:, ...". */
+ private static final Pattern OPTION_AT_EIP = Pattern.compile(
+ "^Line (\\d+): must have at most 1 properties \\((\\w+): lines up
with (\\w+):, so it is read as a second key");
+
+ /** How many lines are shown at most; a longer block is said in words
after them. */
+ private static final int MAX_LINES = 12;
+
+ private IndentLines() {
+ }
+
+ /** The messages, with the lines to write after each message of an option
at the column of its EIP. */
+ static List<String> withLines(List<String> messages, String content) {
+ if (messages.isEmpty() || content == null) {
+ return messages;
+ }
+ String[] lines = content.split("\n", -1);
+ List<String> answer = new ArrayList<>(messages.size());
+ for (String message : messages) {
+ Matcher m = OPTION_AT_EIP.matcher(message);
+ String written = m.find() ? linesToWrite(lines,
Integer.parseInt(m.group(1)) - 1, m.group(2)) : null;
+ answer.add(written != null ? message + written : message);
+ }
+ return answer;
+ }
+
+ /** " Write lines 15-17 as:" and the lines two spaces further right; null
when the key is not found. */
+ static String linesToWrite(String[] lines, int itemIndex, String key) {
+ if (itemIndex < 0 || itemIndex >= lines.length) {
+ return null;
+ }
+ String item = lines[itemIndex];
+ int dash = indent(item);
+ if (!item.startsWith("- ", dash)) {
+ return null;
+ }
+ int keyColumn = dash + 2;
+ for (int j = itemIndex + 1; j < lines.length; j++) {
+ String l = lines[j];
+ if (l.isBlank()) {
+ continue;
+ }
+ int i = indent(l);
+ if (i < keyColumn) {
+ return null; // past the item
+ }
+ if (i == keyColumn && l.startsWith(key + ":", i)) {
+ int end = blockEnd(lines, j);
+ StringBuilder sb = new StringBuilder(" Write lines ").append(j
+ 1).append('-').append(end)
+ .append(" as:");
+ int shown = 0;
+ for (int k = j; k < end; k++) {
+ if (shown == MAX_LINES) {
+ sb.append("\n(and the ").append(end - k).append("
lines after them, two spaces further right too)");
+ break;
+ }
+ sb.append('\n').append(lines[k].isBlank() ? "" : " " +
lines[k]);
+ shown++;
+ }
+ return sb.toString();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The end (exclusive) of the block of a key: the lines below it that are
more indented, and the items of a list at
+ * the key's own column (YAML allows them there), without the blank lines
at its end.
+ */
+ private static int blockEnd(String[] lines, int start) {
+ int col = indent(lines[start]);
+ boolean keyWithoutValue = lines[start].strip().endsWith(":");
+ int end = start + 1;
+ while (end < lines.length) {
+ String l = lines[end];
+ if (l.isBlank() || indent(l) > col
+ || keyWithoutValue && indent(l) == col && l.startsWith("-
", col)) {
+ end++;
+ } else {
+ break;
+ }
+ }
+ while (end > start + 1 && lines[end - 1].isBlank()) {
+ end--;
+ }
+ return end;
+ }
+
+ private static int indent(String line) {
+ int i = 0;
+ while (i < line.length() && line.charAt(i) == ' ') {
+ i++;
+ }
+ return i;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index 2de9e60532c1..08a9d45a4a97 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -150,6 +150,9 @@ public final class SourceValidator {
// a header read where the route keeps the name in an exchange
property, or the reverse (CAMEL-25516)
msgs = new ArrayList<>(HeaderPropertyMixups.validate(content));
}
+ // an option at the column of its EIP: the lines to write, which a
model copies (the words it does not,
+ // CAMEL-25556)
+ msgs = IndentLines.withLines(msgs, content);
return KameletChecks.withTemplateHints(name, content, msgs);
}
if (name.endsWith(".properties")) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLoopTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLoopTest.java
new file mode 100644
index 000000000000..7e215063cfa7
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLoopTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A model that does not see the problem in its own lines writes them again,
as it was told: the lines to write for an
+ * option at the column of its EIP, and a write the same as the last refused
one says so.
+ */
+class AuthoringToolsLoopTest {
+
+ private static final String FILE = "demo.camel.yaml";
+
+ // as the local model wrote it sixteen times in a row: steps: at the
column of split:
+ private static final String STEPS_NEXT_TO_SPLIT = """
+ - route:
+ from:
+ uri: timer:orders
+ steps:
+ - split:
+ expression:
+ simple: "${body}"
+ steps:
+ - log:
+ message: "pick ${body}"
+ """;
+
+ @Test
+ void theHintShowsTheLinesToWrite() {
+ List<String> errors = SourceValidator.validate(FILE,
STEPS_NEXT_TO_SPLIT, new DefaultCamelCatalog(), null);
+ assertThat(errors).singleElement().satisfies(m -> assertThat(m)
+ .contains("steps: lines up with split:")
+ .endsWith(" Write lines 8-10 as:\n"
+ + " steps:\n"
+ + " - log:\n"
+ + " message: \"pick ${body}\""));
+ }
+
+ @Test
+ void theSameRefusedContentAgainIsSaidToBeTheSame(@TempDir Path dir) throws
IOException {
+ JsonObject first = write(dir, STEPS_NEXT_TO_SPLIT);
+ assertThat(first.getString("status")).isEqualTo("invalid");
+ assertThat(first.get("repeated")).isNull();
+
+ JsonObject second = write(dir, STEPS_NEXT_TO_SPLIT);
+ assertThat(second.getInteger("repeated")).isEqualTo(2);
+ assertThat(second.getString("message"))
+ .contains("the same as your last write of it, refused 2 times
now")
+ .contains("where an error shows the lines to write, copy
them");
+ assertThat(write(dir,
STEPS_NEXT_TO_SPLIT).getInteger("repeated")).isEqualTo(3);
+
+ // other content starts again, and a write that goes through forgets it
+ assertThat(write(dir, STEPS_NEXT_TO_SPLIT.replace("pick",
"take")).get("repeated")).isNull();
+ assertThat(write(dir,
+ STEPS_NEXT_TO_SPLIT.replace(" steps:\n -
log:\n message: \"pick ${body}\"",
+ " steps:\n - log:\n
message: \"pick ${body}\""))
+ .getString("status")).isIn("created", "overwritten");
+ assertThat(write(dir, STEPS_NEXT_TO_SPLIT).get("repeated")).isNull();
+ }
+
+ @Test
+ void theSameRefusedEditAgainIsSaidToBeTheSame(@TempDir Path dir) throws
IOException {
+ Files.writeString(dir.resolve(FILE), """
+ - route:
+ from:
+ uri: timer:orders
+ steps:
+ - log:
+ message: "order ${body}"
+ """);
+ String find = " - log:\n message: \"order ${body}\"";
+ String replace = """
+ - split:
+ expression:
+ simple: "${body}"
+ steps:
+ - log:
+ message: "pick ${body}\"""";
+ assertThat(edit(dir, find,
replace).getString("message")).startsWith("The file was not changed: the
result");
+ JsonObject again = edit(dir, find, replace);
+ assertThat(again.getInteger("repeated")).isEqualTo(2);
+ assertThat(again.getString("message")).contains("the edit gives the
same content as your last attempt");
+ }
+
+ private static JsonObject write(Path dir, String content) {
+ return AuthoringTools.writeFile(new ToolContext(), dir, FILE, content,
true);
+ }
+
+ private static JsonObject edit(Path dir, String find, String replace) {
+ return AuthoringTools.editFile(new ToolContext(), dir, FILE, find,
replace);
+ }
+}