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 303f8c47852c CAMEL-26932: camel-jbang-mcp - a repeated catalog miss
gets a note that says what the answer was, with its suggestions (#29962)
303f8c47852c is described below
commit 303f8c47852c454f0c5c97ee2dc5e50fe65fcfaa
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Nov 14 20:51:39 2026 +0100
CAMEL-26932: camel-jbang-mcp - a repeated catalog miss gets a note that
says what the answer was, with its suggestions (#29962)
CAMEL-26932: camel-jbang - RepeatedToolCalls.repeatOf computes the key of
the call once
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../jbang/core/commands/ai/RepeatedToolCalls.java | 49 ++++++++++++++++++++--
.../core/commands/ai/RepeatedToolCallsTest.java | 37 ++++++++++++++++
.../jbang/core/commands/mcp/AuthoringTools.java | 2 +-
.../dsl/jbang/core/commands/mcp/DiagnoseTools.java | 2 +-
.../core/commands/mcp/RepeatedCallSessions.java | 10 ++++-
.../core/commands/mcp/AuthoringToolsTest.java | 18 ++++++++
6 files changed, 112 insertions(+), 6 deletions(-)
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 ca7f56b57ef7..276ae629601e 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
@@ -19,6 +19,7 @@ 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.Collection;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
@@ -59,6 +60,9 @@ public final class RepeatedToolCalls {
private final Map<String, Integer> counts = new HashMap<>();
+ /** The calls whose answer was an error given as data, such as a name the
catalog has not got, with that answer. */
+ private final Map<String, JsonObject> misses = new HashMap<>();
+
/**
* Returns the short note to answer with instead of running the tool, or
null when the tool should run and answer in
* full: a tool that is not deterministic, or one of the first {@value
#FULL_ANSWERS} identical calls. When this
@@ -71,21 +75,41 @@ public final class RepeatedToolCalls {
}
if (counts.size() >= MAX_TRACKED) {
counts.clear();
+ misses.clear();
}
// Peek at the current count without incrementing. The increment
happens only after a successful answer
// (recordSuccessfulAnswer), so a call that throws an exception does
not consume the retry budget.
- int current = counts.getOrDefault(key(tool.name(), args,
tool.deterministicWhen()), 0);
+ String key = key(tool.name(), args, tool.deterministicWhen());
+ int current = counts.getOrDefault(key, 0);
if (current < FULL_ANSWERS) {
// Will be incremented by recordSuccessfulAnswer; answer in full.
return null;
}
// Already answered FULL_ANSWERS times; the next identical call gets
the note.
// Increment so timesAsked keeps growing (allows the model to see the
repetition count rising).
- int times = counts.merge(key(tool.name(), args,
tool.deterministicWhen()), 1, Integer::sum);
+ int times = counts.merge(key, 1, Integer::sum);
JsonObject note = new JsonObject();
note.put("repeated", true);
note.put("tool", tool.name());
note.put("timesAsked", times);
+ JsonObject miss = misses.get(key);
+ if (miss != null) {
+ // the hint of the tool is for an answer that was found, such as
writing the file with the options it
+ // gave; a miss gives no options, so the note says what the answer
was and what to ask instead (CAMEL-26932)
+ Object suggestions = miss.get("suggestions");
+ note.put("note", "The answer was: " + miss.getString("error") + ".
"
+ + (suggestions instanceof Collection<?> c &&
!c.isEmpty()
+ ? "Use one of the suggestions instead: "
+ String.join(", ", c.stream()
+ .map(String::valueOf).toList())
+ + "."
+ : "Ask with other arguments instead.")
+ + " You asked this " + times + " times now with
the same arguments, and the answer is the"
+ + " same each time.");
+ if (suggestions != null) {
+ note.put("suggestions", suggestions);
+ }
+ return note;
+ }
// 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"
@@ -98,18 +122,37 @@ public final class RepeatedToolCalls {
* {@value #FULL_ANSWERS} budget; calls that throw an exception do not.
*/
public synchronized void recordSuccessfulAnswer(ToolDescriptor tool,
Map<String, ?> args) {
+ recordSuccessfulAnswer(tool, args, null);
+ }
+
+ /**
+ * As {@link #recordSuccessfulAnswer(ToolDescriptor, Map)}, with the
answer: an answer with an {@code error}, such
+ * as a name the catalog has not got, is a miss, and the note for
repeating it says so instead of the hint of the
+ * tool. A miss still counts, so the model is still stopped from asking
the same again and again (CAMEL-26875).
+ *
+ * @param answer the answer of the tool, or null when it is not JSON
+ */
+ public synchronized void recordSuccessfulAnswer(ToolDescriptor tool,
Map<String, ?> args, JsonObject answer) {
if (tool == null || !tool.isDeterministic(args)) {
return;
}
if (counts.size() >= MAX_TRACKED) {
counts.clear();
+ misses.clear();
+ }
+ String key = key(tool.name(), args, tool.deterministicWhen());
+ counts.merge(key, 1, Integer::sum);
+ if (answer != null && answer.get("error") instanceof String) {
+ misses.put(key, answer);
+ } else {
+ misses.remove(key);
}
- counts.merge(key(tool.name(), args, tool.deterministicWhen()), 1,
Integer::sum);
}
/** Forgets the calls so far: a new session starts. */
public synchronized void reset() {
counts.clear();
+ misses.clear();
}
/**
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 c7bd71cb5a79..109938f5a82c 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
@@ -19,6 +19,7 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
import java.util.List;
import java.util.Map;
+import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import org.junit.jupiter.api.Test;
@@ -63,6 +64,42 @@ class RepeatedToolCallsTest {
assertThat(fourth.getString("note")).contains("You asked this 4 times
now");
}
+ @Test
+ void aRepeatedMissSaysWhatTheAnswerWasAndStillCounts() {
+ // the hint of the tool, to write the file with the options the answer
gave, did not fit a miss (CAMEL-26932)
+ Map<String, String> kafak = Map.of("name", "kafak");
+ JsonObject miss = new JsonObject();
+ miss.put("error", "Artifact not found: kafak");
+ miss.put("suggestions", new JsonArray(List.of("kafka")));
+ for (int i = 0; i < 2; i++) {
+ assertThat(calls.repeatOf(catalogDoc, kafak)).isNull();
+ calls.recordSuccessfulAnswer(catalogDoc, kafak, miss);
+ }
+
+ JsonObject note = calls.repeatOf(catalogDoc, kafak);
+ assertThat(note.getBoolean("repeated")).isTrue();
+ assertThat(note.getInteger("timesAsked")).isEqualTo(3);
+ assertThat(note.getString("note"))
+ .startsWith("The answer was: Artifact not found: kafak. Use
one of the suggestions instead: kafka.")
+ .contains("You asked this 3 times now")
+ .doesNotContain("camel_write_file");
+ assertThat(note.get("suggestions")).isEqualTo(List.of("kafka"));
+ }
+
+ @Test
+ void aRepeatedMissWithoutSuggestionsSaysToAskWithOtherArguments() {
+ Map<String, String> nope = Map.of("name", "nosuchthing");
+ JsonObject miss = new JsonObject();
+ miss.put("error", "Artifact not found: nosuchthing");
+ calls.recordSuccessfulAnswer(catalogDoc, nope, miss);
+ calls.recordSuccessfulAnswer(catalogDoc, nope, miss);
+
+ JsonObject note = calls.repeatOf(catalogDoc, nope);
+ assertThat(note.getString("note"))
+ .startsWith("The answer was: Artifact not found: nosuchthing.
Ask with other arguments instead.");
+ assertThat(note.get("suggestions")).isNull();
+ }
+
/**
* 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
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 de114e097042..1e722486bfd6 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
@@ -537,7 +537,7 @@ public class AuthoringTools {
}
JsonObject result = call(tool, args, catalogService, configure);
if (repeatedCalls != null) {
- repeatedCalls.recordSuccessfulAnswer(connection, tool, args);
+ repeatedCalls.recordSuccessfulAnswer(connection, tool, args,
result);
}
return result;
}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseTools.java
index 0d6a7ac4a836..4fdeea8e0990 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseTools.java
@@ -82,7 +82,7 @@ public class DiagnoseTools {
CamelCatalog catalog = catalogService.loadCatalog(runtime,
camelVersion, platformBom);
JsonObject answer = ErrorDiagnoser.diagnose(error, catalog);
if (repeatedCalls != null) {
- repeatedCalls.recordSuccessfulAnswer(connection,
"camel_error_diagnose", diagArgs);
+ repeatedCalls.recordSuccessfulAnswer(connection,
"camel_error_diagnose", diagArgs, answer);
}
return answer;
} catch (ToolCallException e) {
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RepeatedCallSessions.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RepeatedCallSessions.java
index be253d7c4134..8807ded9e691 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RepeatedCallSessions.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RepeatedCallSessions.java
@@ -65,6 +65,14 @@ public class RepeatedCallSessions {
* budget (CAMEL-25640).
*/
void recordSuccessfulAnswer(McpConnection connection, String tool,
Map<String, String> args) {
+ recordSuccessfulAnswer(connection, tool, args, null);
+ }
+
+ /**
+ * As {@link #recordSuccessfulAnswer(McpConnection, String, Map)}, with
the answer, so a repeated miss, such as a
+ * name the catalog has not got, gets a note of its own (CAMEL-26932).
+ */
+ void recordSuccessfulAnswer(McpConnection connection, String tool,
Map<String, String> args, JsonObject answer) {
if (connection == null || connection.id() == null) {
return;
}
@@ -73,7 +81,7 @@ public class RepeatedCallSessions {
calls = sessions.get(connection.id());
}
if (calls != null) {
- calls.recordSuccessfulAnswer(ToolRegistry.findTool(tool), args);
+ calls.recordSuccessfulAnswer(ToolRegistry.findTool(tool), args,
answer);
}
}
}
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 bdefe36df6ca..95a5f3196ec1 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
@@ -264,6 +264,24 @@ class AuthoringToolsTest {
}
}
+ @Test
+ void aThirdIdenticalCatalogMissGetsTheSuggestionsNotTheWriteHint() {
+ // CAMEL-26932: a miss is answered as data, so it still counts; its
note says what to ask instead
+ AuthoringTools counted = new AuthoringTools();
+ counted.repeatedCalls = new RepeatedCallSessions();
+ McpConnection agent = connection("agent-1");
+
+ JsonObject first = counted.camel_catalog_doc(agent, "kafak", null,
null, null, null, null, null, null, null, null);
+ counted.camel_catalog_doc(agent, "kafak", null, null, null, null,
null, null, null, null, null);
+ JsonObject third = counted.camel_catalog_doc(agent, "kafak", null,
null, null, null, null, null, null, null, null);
+
+ assertThat(first.getString("error")).contains("kafak");
+ assertThat(third.getBoolean("repeated")).isTrue();
+ assertThat(third.getString("note")).startsWith("The answer was:
Artifact not found: kafak.")
+ .contains("Use one of the suggestions instead: kafka.")
+ .doesNotContain("camel_write_file");
+ }
+
@Test
void aThirdIdenticalCatalogCallOfAConnectionGetsAShortNote() {
AuthoringTools counted = new AuthoringTools();