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 42060dc5ce74 CAMEL-26875: camel-jbang - AI tools: the answer to a
repeated read says what to do next (#29697)
42060dc5ce74 is described below
commit 42060dc5ce74fd50680c27707dab0351f5f5e75c
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Nov 12 13:09:12 2026 +0100
CAMEL-26875: camel-jbang - AI tools: the answer to a repeated read says
what to do next (#29697)
The note for the third and later identical call of a deterministic tool now
starts with the next step, then says the answer is the same each time; its
count is timesAsked. camel_catalog_sample and camel_catalog_find get a hint
(write the file, which checks it), and camel_catalog_doc says to go on with
the options it gave. In the local-model benchmark (r32), a model got the old
note, which only said the answer was already given, and asked again until
its tool calls ran out, without writing the test.
Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 12 ++--
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 22 ++++--
.../jbang/core/commands/ai/RepeatedToolCalls.java | 18 +++--
.../dsl/jbang/core/commands/ai/ToolDescriptor.java | 6 +-
.../core/commands/ai/RepeatedToolCallsTest.java | 78 ++++++++++++++++++++--
.../core/commands/mcp/AuthoringToolsTest.java | 11 ++-
6 files changed, 122 insertions(+), 25 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 67415f5f6890..2fb95d1b16f0 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -541,11 +541,15 @@ with `"_meta": {"camel.apache.org/deterministic": true}`,
so a client can notice
An agent asking one of them the same question again and again gets nothing
new, and a small local model can get
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.
+in full, and from the third the answer is a short note instead. The note
starts with what to do next. Then it says
+how many times the call was asked, and that the answer is the same each time
and is in the earlier tool result.
+The changed answer helps the agent break the loop, and the context is not
filled with copies.
+The next step depends on the tool. For `camel_catalog_sample` it is to write
the file with `camel_write_file`,
+which checks the file and says what to change. For `camel_catalog_find` it is
to use a match, or to ask with
+another term, and then write the file. For `camel_catalog_doc` it is to go on
with the options it gave; when a page
+was cut, `docPage=<section title>` returns a section it left out.
`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.
For `camel_catalog_doc` the note says
-how to get the sections that a cut page left out: `docPage=<section title>`
returns one section. Validating a file on disk, or
+the same answer, and the note 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.
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 f7355e56b2c3..57d9e74d1607 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
@@ -131,8 +131,11 @@ public final class AuthoringTools {
.param("camelVersion", "string", VERSION_DESC, false)
.core(true)
.deterministic(true)
- .repeatHint("When that answer was cut, it named the sections
it left out: docPage=<section title> returns"
- + " one of them.")
+ // r32: three doc calls were each made 10 to 21 times with the
same arguments; the write checks the
+ // options, so writing is the next step (CAMEL-26875)
+ .repeatHint("Go on with the options the earlier answer gave:
write the file with camel_write_file, which"
+ + " checks the options against the catalog. When
that answer was cut, it named the sections"
+ + " it left out: docPage=<section title> returns
one of them.")
.executor((ctx, args) -> {
applyVersion(ctx, args);
return CatalogDocs.catalogDoc(ctx.catalog(),
args.get("name"), args.get("endpoint"),
@@ -154,6 +157,8 @@ public final class AuthoringTools {
.param("camelVersion", "string", VERSION_DESC, false)
.core(true)
.deterministic(true)
+ .repeatHint("Use a match from the earlier answer, or ask with
another term; then write the file with"
+ + " camel_write_file.")
.executor((ctx, args) -> {
applyVersion(ctx, args);
return CatalogDocs.find(ctx.catalog(), args.get("term"),
args.get("kind"),
@@ -176,6 +181,12 @@ public final class AuthoringTools {
.param("camelVersion", "string", VERSION_DESC, false)
.core(true)
.deterministic(true)
+ // r32: a sample that does not exist asked for again and again
in a Citrus test step, until the calls of
+ // the step were spent and no test was written; the write
checks the test and names the actions Citrus
+ // has (CAMEL-26875)
+ .repeatHint("Write the file now with camel_write_file: when
the earlier answer had no sample, asking again"
+ + " gives none either. The write checks the file,
and its answer says what to change (for a"
+ + " Citrus test: which actions it takes).")
.executor((ctx, args) -> {
applyVersion(ctx, args);
return CatalogSamples.sample(ctx.catalog(),
args.get("kind"), args.get("name"),
@@ -195,8 +206,8 @@ public final class AuthoringTools {
// 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.")
+ .repeatHint("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");
@@ -414,8 +425,7 @@ public final class AuthoringTools {
// 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.")
+ .repeatHint("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/RepeatedToolCalls.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
index 2b1350d075f2..ca7f56b57ef7 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
@@ -33,6 +33,13 @@ import org.apache.camel.util.json.JsonObject;
* the input does change and the context is not filled with copies. The first
two calls are always answered in full, so
* a legitimate re-read still works.
* <p>
+ * The note starts with what to do next, and then says that the answer is the
same each time. A note that only said the
+ * answer was already given did not stop a small model. In the local-model
benchmark (r32), a model asked for a sample
+ * that does not exist, and then asked the same 11 or 12 more times, in 3 runs
of a step. It said sorry for the repeated
+ * calls and asked again, until its 25 tool calls were spent. It never wrote
the test of the step. So the note starts
+ * with the next step: the {@link ToolDescriptor#repeatHint() hint} of the
tool, such as writing the file, whose checks
+ * say what to change, or else to go on with the task.
+ * <p>
* One instance tracks one session: a client connection, or a conversation of
an AI panel. Call {@link #reset()} when a
* new session starts on the same instance.
*/
@@ -44,6 +51,9 @@ public final class RepeatedToolCalls {
/** How many identical calls are answered in full before the short note is
given. */
static final int FULL_ANSWERS = 2;
+ /** The next step the note starts with when the tool has no {@link
ToolDescriptor#repeatHint() hint} of its own. */
+ static final String NEXT_STEP = "Go on with the next step of your task.";
+
/** Guards against unbounded growth over a very long session; the oldest
counts are simply forgotten. */
private static final int MAX_TRACKED = 1000;
@@ -76,10 +86,10 @@ public final class RepeatedToolCalls {
note.put("repeated", true);
note.put("tool", tool.name());
note.put("timesAsked", times);
- note.put("note", tool.name() + " was already called with these same
arguments " + (times - 1)
- + " times in this session. Its answer does not
change, so it is not sent again: it is in your"
- + " earlier tool result."
- + (tool.repeatHint() != null ? " " +
tool.repeatHint() : ""));
+ // the next step first, then the fact; the count is timesAsked, this
call included (CAMEL-26875)
+ note.put("note", (tool.repeatHint() != null ? tool.repeatHint() :
NEXT_STEP) + " You asked this " + times
+ + " times now with the same arguments, and the answer
is the same each time: it is in your"
+ + " earlier tool result.");
return note;
}
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 3a9921263ca0..dfaee5fe0e11 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
@@ -148,7 +148,11 @@ public class ToolDescriptor {
return this;
}
- /** What the note of a repeated call adds for this tool: what to do
instead of asking again. */
+ /**
+ * What the note of a repeated call starts with for this tool: the next
step instead of asking again, such as
+ * writing the file, written as one or more whole sentences. Without a
hint the note says to go on with the next
+ * step of the task, see {@link RepeatedToolCalls}.
+ */
public ToolDescriptor repeatHint(String hint) {
repeatHint = hint;
return this;
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 6fb0029a7e71..c7bd71cb5a79 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
@@ -26,6 +26,10 @@ import static org.assertj.core.api.Assertions.assertThat;
class RepeatedToolCallsTest {
+ /** What the note says after the next step, on the third identical call. */
+ private static final String ASKED_3_TIMES = "You asked this 3 times now
with the same arguments, and the answer is"
+ + " the same each time: it is
in your earlier tool result.";
+
private final RepeatedToolCalls calls = new RepeatedToolCalls();
private final ToolDescriptor catalogDoc =
ToolRegistry.findTool("camel_catalog_doc");
@@ -47,11 +51,73 @@ class RepeatedToolCallsTest {
assertThat(note.getBoolean("repeated")).isTrue();
assertThat(note.getString("tool")).isEqualTo("camel_catalog_doc");
assertThat(note.getInteger("timesAsked")).isEqualTo(3);
- assertThat(note.getString("note")).contains("2 times");
+ // the next step first, then the fact, with the count of timesAsked
(CAMEL-26875)
+ assertThat(note.getString("note"))
+ .startsWith("Go on with the options the earlier answer gave:
write the file with camel_write_file")
+ .endsWith(ASKED_3_TIMES);
// a page that was cut was not answered in full: the note says how to
get the sections it left out
assertThat(note.getString("note")).doesNotContain("in
full").contains("docPage=<section title>");
- assertThat(calls.repeatOf(catalogDoc,
sql).getInteger("timesAsked")).isEqualTo(4);
+ JsonObject fourth = calls.repeatOf(catalogDoc, sql);
+ assertThat(fourth.getInteger("timesAsked")).isEqualTo(4);
+ assertThat(fourth.getString("note")).contains("You asked this 4 times
now");
+ }
+
+ /**
+ * r32, a Citrus test step: camel_catalog_sample had no sample of 'send
message' as an EIP, and the model asked the
+ * same 11 or 12 more times, until its tool calls were spent, without
writing the test. The note starts with the
+ * next step: write the file, whose checks say what to change
(CAMEL-26875).
+ */
+ @Test
+ void aRepeatedSampleCallIsToldToWriteTheFile() {
+ ToolDescriptor sample = ToolRegistry.findTool("camel_catalog_sample");
+ Map<String, String> sendMessage = Map.of("name", "send message",
"kind", "eip");
+ successfulCall(sample, sendMessage);
+ successfulCall(sample, sendMessage);
+
+ JsonObject note = calls.repeatOf(sample, sendMessage);
+ assertThat(note.getString("tool")).isEqualTo("camel_catalog_sample");
+ assertThat(note.getInteger("timesAsked")).isEqualTo(3);
+ assertThat(note.getString("note")).isEqualTo("Write the file now with
camel_write_file: when the earlier answer"
+ + " had no sample, asking
again gives none either. The write checks"
+ + " the file, and its
answer says what to change (for a Citrus test:"
+ + " which actions it
takes). " + ASKED_3_TIMES);
+ }
+
+ @Test
+ void aRepeatedFindIsToldToUseAMatchOrAnotherTerm() {
+ ToolDescriptor find = ToolRegistry.findTool("camel_catalog_find");
+ Map<String, String> term = Map.of("term", "send message");
+ successfulCall(find, term);
+ successfulCall(find, term);
+
+ assertThat(calls.repeatOf(find, term).getString("note"))
+ .isEqualTo("Use a match from the earlier answer, or ask with
another term; then write the file with"
+ + " camel_write_file. " + ASKED_3_TIMES);
+ }
+
+ @Test
+ void aToolWithoutAHintIsToldToGoOnWithTheTask() {
+ ToolDescriptor diagnose =
ToolRegistry.findTool("camel_error_diagnose");
+ assertThat(diagnose.repeatHint()).isNull();
+ Map<String, String> error = Map.of("error", "No endpoint could be
found for: xyz:test");
+ successfulCall(diagnose, error);
+ successfulCall(diagnose, error);
+
+ assertThat(calls.repeatOf(diagnose, error).getString("note"))
+ .isEqualTo("Go on with the next step of your task. " +
ASKED_3_TIMES);
+ }
+
+ /** The note starts with the hint of the tool, and a sentence of its own
follows: a hint is whole sentences. */
+ @Test
+ void everyHintIsWholeSentences() {
+ for (ToolDescriptor tool : ToolRegistry.allTools()) {
+ String hint = tool.repeatHint();
+ if (hint != null) {
+
assertThat(Character.isUpperCase(hint.charAt(0))).as(tool.name() + ": " +
hint).isTrue();
+ assertThat(hint).as(tool.name()).endsWith(".");
+ }
+ }
}
/**
@@ -68,10 +134,8 @@ class RepeatedToolCallsTest {
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.");
+ assertThat(note.getString("note")).isEqualTo("Change the expression or
the message (body, headers, variables) to"
+ + " learn something new.
" + ASKED_3_TIMES);
// another message is another question
assertThat(calls.repeatOf(eval, Map.of("expression",
"body.getClass().getName()", "language", "groovy", "body",
"[]"))).isNull();
@@ -152,7 +216,7 @@ class RepeatedToolCallsTest {
successfulCall(validate, args);
JsonObject note = calls.repeatOf(validate, args);
assertThat(note).isNotNull();
- assertThat(note.getString("note")).contains("change the line the error
names");
+ assertThat(note.getString("note")).startsWith("Change the line the
error names");
Map<String, String> fromDisk = Map.of("file", "route.camel.yaml");
for (int i = 0; i < 4; i++) {
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 7873744e3103..c7e1b4f6a3b2 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
@@ -277,7 +277,10 @@ class AuthoringToolsTest {
assertThat(second).isEqualTo(first);
assertThat(third.getBoolean("repeated")).isTrue();
assertThat(third.getInteger("timesAsked")).isEqualTo(3);
- assertThat(third.getString("note")).contains("camel_catalog_doc", "2
times");
+ assertThat(third.getString("tool")).isEqualTo("camel_catalog_doc");
+ // the next step first, then the fact (CAMEL-26875)
+ assertThat(third.getString("note")).startsWith("Go on with the options
the earlier answer gave")
+ .contains("You asked this 3 times now");
assertThat(third.toJson().length()).isLessThan(first.toJson().length()
/ 4);
// another question, or another connection, is answered in full
@@ -302,7 +305,8 @@ class AuthoringToolsTest {
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");
+ assertThat(third.getString("tool")).isEqualTo("camel_validate_source");
+ assertThat(third.getString("note")).startsWith("Change the line the
error names");
// changed content is a new question
assertThat(counted.camel_validate_source(agent, null, null,
"route.camel.yaml", content + "\n", null)
.get("repeated")).isNull();
@@ -325,7 +329,8 @@ class AuthoringToolsTest {
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");
+
assertThat(third.getString("tool")).isEqualTo("camel_eval_expression");
+ assertThat(third.getString("note")).startsWith("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)