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 11d2f7e95205 CAMEL-24834: camel-jbang - load AI tool groups (SQL,
tracing, resilience) from what the selected integration has (#27318)
11d2f7e95205 is described below
commit 11d2f7e952051509635af474f1dc69263bb3e084
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Oct 4 16:34:40 2026 +0200
CAMEL-24834: camel-jbang - load AI tool groups (SQL, tracing, resilience)
from what the selected integration has (#27318)
* CAMEL-24834: load AI tool groups (SQL, tracing, resilience) from what the
selected app has, read-only SQL for local models
The runtime status of an integration now tells which tool groups a small
model needs: AppFeatures reads the
datasources, SQL endpoints, circuit breakers, OpenTelemetry, message
tracing and Micrometer from the status file,
ToolGroups picks the sql, tracing and resilience groups with one line of
guidance each, and SqlReadOnlyGuard
refuses SQL that writes.
- shared registry: query_sql (read-only SQL) and get_tool_groups
- camel-jbang-mcp: camel_runtime_sql_query and camel_runtime_tool_groups,
both read-only
- AI panel in camel-jbang views: the core tool set gets the groups of the
selected integration, read again only when
another integration is selected or it reloads; SQL is read-only unless
camel.tui.ai.sqlWrites=true
- docs for the tool groups, the new MCP tools and the setting
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-24834: SQL in the tool group is not limited to reading (developer
tool)
The AI panel and the MCP server are developer tools, not meant to reach
production databases, so the sql tool
group offers the SQL tools as they are: camel_runtime_sql,
camel_runtime_datasources and camel_runtime_sql_trace
over MCP, tui_execute_sql and tui_update_row in the AI panel. Removes
SqlReadOnlyGuard, query_sql /
camel_runtime_sql_query, the sqlWrites argument and setting, and the SQL
mode in the fingerprint.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-24834: review - newer features win on merge, consistent volatile
fields, broader status read guard
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 38 +++
.../modules/ROOT/pages/camel-jbang-tui-ai.adoc | 2 +-
.../ROOT/pages/camel-jbang-tui-local-models.adoc | 26 ++
.../dsl/jbang/core/commands/ai/AppFeatures.java | 284 +++++++++++++++++++++
.../dsl/jbang/core/commands/ai/ToolGroup.java | 42 +++
.../dsl/jbang/core/commands/ai/ToolGroups.java | 178 +++++++++++++
.../dsl/jbang/core/commands/ai/ToolRegistry.java | 53 ++++
.../jbang/core/commands/ai/AppFeaturesTest.java | 132 ++++++++++
.../dsl/jbang/core/commands/ai/ToolGroupsTest.java | 114 +++++++++
.../jbang/core/commands/ai/ToolRegistryTest.java | 33 +++
.../dsl/jbang/core/commands/mcp/RuntimeTools.java | 11 +
.../jbang/core/commands/mcp/RuntimeToolsTest.java | 14 +-
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 103 +++++++-
.../dsl/jbang/core/commands/tui/McpFacade.java | 22 ++
.../dsl/jbang/core/commands/tui/TuiToolGroups.java | 106 ++++++++
.../jbang/core/commands/tui/TuiToolRegistry.java | 11 +
.../core/commands/tui/AiPanelPromptBudgetTest.java | 48 ++++
.../core/commands/tui/AiPanelToolGroupsTest.java | 176 +++++++++++++
18 files changed, 1387 insertions(+), 6 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 9ba4fd6371a6..1586b3770178 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -798,6 +798,10 @@ process is running).
the update count otherwise. Lets the model look at the data a route reads or
writes, or try a statement
before putting it in a route.
+| `camel_runtime_tool_groups`
+| Which runtime tool groups (`sql`, `tracing`, `resilience`) the application
needs, from what its status shows.
+ See <<_tool_groups_for_local_models>>.
+
| `camel_runtime_datasources`
| Datasource connection pool status: active, idle and total connections, max
pool size and waiting threads.
@@ -827,6 +831,40 @@ process is running).
| Compares the running route definitions with the source files to detect
configuration drift.
|===
+==== Tool groups for local models
+
+A client that drives a small local model pays for every tool schema on every
request. Rather than listing every
+runtime tool, it can offer the core tools plus the groups the selected
application needs, which
+`camel_runtime_tool_groups` works out from the application's status:
+
+[cols="1,2,2",options="header"]
+|===
+| Group | Loads when the application has | Tools
+
+| `sql`
+| a datasource, a `sql`, `sql-stored`, `jdbc`, `spring-jdbc` or `jpa`
endpoint, or traced SQL statements
+| `camel_runtime_sql`, `camel_runtime_datasources`, `camel_runtime_sql_trace`
+
+| `tracing`
+| OpenTelemetry, enabled message tracing, or Micrometer
+| `camel_runtime_trace`, plus `camel_runtime_spans` with OpenTelemetry and
`camel_runtime_metrics` with Micrometer
+
+| `resilience`
+| a circuit breaker in a route, or circuit breakers in the Resilience4j or
Fault Tolerance status
+| `camel_runtime_circuit_breakers`
+|===
+
+The answer names the application and its pid, lists the `core` tools (the
shared `camel_*` tools a small model
+should always get), each loaded group with its tools and one line of guidance
for the system prompt (for example
+`SQL: datasource(s) orders (HikariCP), used by sql endpoints. Table names come
from the SQL trace
+(camel_runtime_sql_trace); don't guess a schema.`), and the `signals` in the
status that loaded each group. Its
+`fingerprint` stays the same as long as the groups and the datasources do,
+so a client calls the tool when the user selects an application or the
application reloads, and rebuilds its
+tool list and prompt only when the fingerprint changes; the prompt then stays
the same and a local model keeps
+its prompt cache. A status written by an older Camel version may lack some of
the keys, which just loads fewer
+groups. The Camel TUI does the same for its AI panel, see
+xref:camel-jbang-tui-local-models.adoc#_tool_groups_from_the_selected_integration[Tool
groups from the selected integration].
+
==== Configuration and Registry
[cols="1,3",options="header"]
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
index 66c647cce0ff..4dbfcf0f7314 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
@@ -64,7 +64,7 @@ cycles backward.
| Show or switch how file writes by the model (`camel_write_file`) are
handled. `confirm` (default) shows the confirm dialog for every write, whatever
the model passes; `auto` lets the model skip the dialog with `confirm=false`;
`live` replays the edit in the Source editor so you watch it happen (see
below). The mode lasts for the session.
| `/tools [auto\|core\|full]` (`/t`)
-| Show which tool set is sent to the model, or switch it. `auto` (default)
sends the core set to local providers and every tool to hosted ones; the choice
is saved as `camel.tui.ai.tools`.
+| Show which tool set is sent to the model, or switch it. `auto` (default)
sends the core set to local providers and every tool to hosted ones; the choice
is saved as `camel.tui.ai.tools`. With the core set it also shows the tool
groups loaded for the selected integration, for example `core (26 of 60 tools),
mode auto (local provider); groups: sql, resilience (from the selected
integration)`, see
xref:camel-jbang-tui-local-models.adoc#_tool_groups_from_the_selected_integration[Tool
groups].
| `/context` (`/ctx`)
| Show what the next request costs: provider and model, tool set, static
prefix size, history size and the session total, and with Ollama the context
window, the prompt size above which the history is compacted and the last
measured prompt. Useful with local models, where prompt size is time.
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-local-models.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-local-models.adoc
index 34e91c9360d8..acf8103abed2 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-local-models.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-local-models.adoc
@@ -57,6 +57,32 @@ model loaded for 30 minutes and for a context window of 32k
or 64k (see <<_worki
`OLLAMA_CONTEXT_LENGTH` overrides it), so follow-up questions reuse the cached
prompt instead of reloading
the model.
+=== Tool groups from the selected integration
+
+On top of the core set, the panel loads the tool groups the selected
integration needs, read from the status
+it writes while it runs:
+
+[cols="1,2,3",options="header"]
+|===
+| Group | Loads when the integration has | What the model gets
+| `sql` | a datasource, a `sql`, `sql-stored`, `jdbc`, `spring-jdbc` or `jpa`
endpoint, or traced SQL statements
+| `tui_execute_sql` and `tui_update_row`, and a line naming the datasources
and their pool; the table names come
+from the *SQL Trace* tab
+| `tracing` | OpenTelemetry, enabled message tracing, or Micrometer
+| a line pointing at `tui_get_spans`, `tui_get_history` and the *Metrics* tab,
which are core tools already
+| `resilience` | a circuit breaker in a route, or circuit breakers in the
Resilience4j or Fault Tolerance status
+| a line naming the routes with a circuit breaker and pointing at the *Circuit
Breaker* tab
+|===
+
+Each loaded group adds one line at the end of the system prompt. The groups
are read again only when you
+select another integration or the selected one reloads its routes (after a
reload the groups only grow), so
+the tools and the prompt stay the same from question to question and Ollama
keeps reusing the cached prompt.
+An integration without any group yet is read again on each question, since one
that just started may not
+have written its status completely. With all three groups the static prefix
grows by about 520 tokens.
+
+The full tool set (`/tools full` and hosted providers) is not affected by the
groups. `/tools` and `/context`
+show the loaded groups.
+
== Working with a local Ollama model
A local model is not a slower version of a hosted one; it spends its time
differently, and the TUI
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeatures.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeatures.java
new file mode 100644
index 000000000000..f72822e8774b
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeatures.java
@@ -0,0 +1,284 @@
+/*
+ * 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.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * What a running integration has that a tool group is for (CAMEL-24834): its
datasources and SQL endpoints, circuit
+ * breakers, OpenTelemetry, message tracing and Micrometer. Read from the
status file the integration writes
+ * ({@code ~/.camel/<pid>-status.json}); a key that is missing (an older
Camel, a console that is not on the classpath)
+ * just means fewer features, never an error.
+ *
+ * @param dataSources the datasources in the registry, by name
+ * @param sqlComponents the SQL components the endpoints and routes use
(sql, sql-stored, jdbc, spring-jdbc, jpa)
+ * @param sqlTraced whether SQL statements have been traced
+ * @param circuitBreaker whether the routes have a circuit breaker
+ * @param circuitBreakerRoutes the routes with a circuit breaker, when known
+ * @param openTelemetry whether OpenTelemetry tracing is on
+ * @param messageTracing whether message tracing is enabled
+ * @param micrometer whether Micrometer metrics are on
+ * @param signals the status keys that gave each feature away,
with what they said
+ */
+public record AppFeatures(
+ List<DataSource> dataSources,
+ List<String> sqlComponents,
+ boolean sqlTraced,
+ boolean circuitBreaker,
+ List<String> circuitBreakerRoutes,
+ boolean openTelemetry,
+ boolean messageTracing,
+ boolean micrometer,
+ Map<String, String> signals) {
+
+ /** The components whose endpoints talk SQL to a datasource. */
+ static final Set<String> SQL_COMPONENTS = Set.of("sql", "sql-stored",
"jdbc", "spring-jdbc", "jpa");
+
+ private static final String CIRCUIT_BREAKER_PROCESSOR = "circuitBreaker";
+ private static final List<String> CIRCUIT_BREAKER_SECTIONS
+ = List.of("resilience4j", "fault-tolerance", "circuit-breaker");
+
+ /**
+ * A datasource of the registry.
+ *
+ * @param name the bean name
+ * @param poolType HikariCP, Agroal or Unknown, null when not known
+ */
+ public record DataSource(String name, String poolType) {
+ }
+
+ public AppFeatures {
+ dataSources = List.copyOf(dataSources);
+ sqlComponents = List.copyOf(sqlComponents);
+ circuitBreakerRoutes = List.copyOf(circuitBreakerRoutes);
+ signals = Collections.unmodifiableMap(new LinkedHashMap<>(signals));
+ }
+
+ /** No integration, or nothing to tell: no tool group loads. */
+ public static AppFeatures none() {
+ return new AppFeatures(List.of(), List.of(), false, false, List.of(),
false, false, false, Map.of());
+ }
+
+ /** The names of the datasources. */
+ public List<String> dataSourceNames() {
+ return dataSources.stream().map(DataSource::name).toList();
+ }
+
+ /** Whether the integration works with a database: a datasource, a SQL
endpoint or a traced statement. */
+ public boolean sql() {
+ return !dataSources.isEmpty() || !sqlComponents.isEmpty() || sqlTraced;
+ }
+
+ /** Whether there is anything to trace or measure: OpenTelemetry, message
tracing or Micrometer. */
+ public boolean tracing() {
+ return openTelemetry || messageTracing || micrometer;
+ }
+
+ /**
+ * Both feature sets together: what an integration had before a reload
still counts after it, so the tools a model
+ * was given do not come and go while it works. Where both know the same
datasource or signal, the other (newer) one
+ * wins: a pool type known only after the pool started is the one the
guidance names.
+ */
+ public AppFeatures merge(AppFeatures other) {
+ if (other == null) {
+ return this;
+ }
+ Map<String, DataSource> ds = new TreeMap<>();
+ for (DataSource d : dataSources) {
+ ds.put(d.name(), d);
+ }
+ for (DataSource d : other.dataSources) {
+ ds.put(d.name(), d);
+ }
+ Set<String> components = new TreeSet<>(sqlComponents);
+ components.addAll(other.sqlComponents);
+ Set<String> routes = new TreeSet<>(circuitBreakerRoutes);
+ routes.addAll(other.circuitBreakerRoutes);
+ Map<String, String> sig = new LinkedHashMap<>(signals);
+ other.signals.forEach(sig::put);
+ return new AppFeatures(
+ new ArrayList<>(ds.values()), new ArrayList<>(components),
sqlTraced || other.sqlTraced,
+ circuitBreaker || other.circuitBreaker, new
ArrayList<>(routes),
+ openTelemetry || other.openTelemetry, messageTracing ||
other.messageTracing,
+ micrometer || other.micrometer, sig);
+ }
+
+ /**
+ * Reads the features from an integration's status document. Pure and
defensive: a null document, a missing key or a
+ * value of an unexpected type yields fewer features.
+ */
+ public static AppFeatures fromStatus(JsonObject status) {
+ if (status == null) {
+ return none();
+ }
+ Map<String, String> signals = new LinkedHashMap<>();
+
+ // datasources: dataSources.dataSources[] with name and poolType
+ Map<String, DataSource> dataSources = new TreeMap<>();
+ for (Map<?, ?> entry : objects(section(status,
"dataSources").get("dataSources"))) {
+ String name = text(entry.get("name"));
+ if (name != null) {
+ dataSources.put(name, new DataSource(name,
text(entry.get("poolType"))));
+ }
+ }
+ if (!dataSources.isEmpty()) {
+ signals.put("dataSources", String.join(",", dataSources.keySet()));
+ }
+
+ // SQL endpoints: the endpoint registry, the route inputs and the
processors that send somewhere
+ Set<String> components = new TreeSet<>();
+ for (Map<?, ?> ep : objects(section(status,
"endpoints").get("endpoints"))) {
+ addSqlComponent(components, text(ep.get("uri")));
+ }
+ List<Map<?, ?>> routes = objects(status.get("routes"));
+ Set<String> breakerRoutes = new TreeSet<>();
+ boolean breakerProcessor = false;
+ for (Map<?, ?> route : routes) {
+ addSqlComponent(components, text(route.get("from")));
+ for (Map<?, ?> p : objects(route.get("processors"))) {
+ addSqlComponent(components, text(p.get("uri")));
+ if
(CIRCUIT_BREAKER_PROCESSOR.equals(text(p.get("processor")))) {
+ breakerProcessor = true;
+ String routeId = text(p.get("routeId"));
+ if (routeId == null) {
+ routeId = text(route.get("routeId"));
+ }
+ if (routeId != null) {
+ breakerRoutes.add(routeId);
+ }
+ }
+ }
+ }
+ List<Map<?, ?>> statements = objects(section(status,
"sqlTrace").get("statements"));
+ for (Map<?, ?> st : statements) {
+ addSqlComponent(components, text(st.get("endpoint")));
+ }
+ if (!components.isEmpty()) {
+ signals.put("endpoints", String.join(",", components));
+ }
+ boolean sqlTraced = false;
+ for (Map<?, ?> st : statements) {
+ if (text(st.get("query")) != null) {
+ sqlTraced = true;
+ break;
+ }
+ }
+ if (sqlTraced) {
+ signals.put("sqlTrace", statements.size() + " statement(s)");
+ }
+
+ // circuit breakers: the circuitBreaker EIP in a route, or the
breakers a console reports
+ if (breakerProcessor) {
+ String where = breakerRoutes.isEmpty() ? "" : " in " +
String.join(",", breakerRoutes);
+ signals.put("routes.processors", CIRCUIT_BREAKER_PROCESSOR +
where);
+ }
+ boolean breakers = false;
+ for (String key : CIRCUIT_BREAKER_SECTIONS) {
+ List<Map<?, ?>> list = objects(section(status,
key).get("circuitBreakers"));
+ if (!list.isEmpty()) {
+ breakers = true;
+ signals.put(key, list.size() + " circuit breaker(s)");
+ for (Map<?, ?> b : list) {
+ String routeId = text(b.get("routeId"));
+ if (routeId != null) {
+ breakerRoutes.add(routeId);
+ }
+ }
+ }
+ }
+
+ // tracing and metrics
+ Set<String> consoles = new TreeSet<>();
+ for (Object id : list(status.get("devConsoles"))) {
+ if (id != null) {
+ consoles.add(id.toString());
+ }
+ }
+ boolean otel = consoles.contains("opentelemetry");
+ boolean micrometer = consoles.contains("micrometer") ||
status.get("micrometer") instanceof Map;
+ if (otel || consoles.contains("micrometer")) {
+ List<String> fired = new ArrayList<>();
+ if (otel) {
+ fired.add("opentelemetry");
+ }
+ if (consoles.contains("micrometer")) {
+ fired.add("micrometer");
+ }
+ signals.put("devConsoles", String.join(",", fired));
+ }
+ if (status.get("micrometer") instanceof Map) {
+ signals.put("micrometer", "present");
+ }
+ boolean tracing = Boolean.TRUE.equals(section(status,
"trace").get("enabled"))
+ || "true".equals(text(section(status,
"trace").get("enabled")));
+ if (tracing) {
+ signals.put("trace.enabled", "true");
+ }
+
+ return new AppFeatures(
+ new ArrayList<>(dataSources.values()), new
ArrayList<>(components), sqlTraced,
+ breakerProcessor || breakers, new ArrayList<>(breakerRoutes),
otel, tracing, micrometer, signals);
+ }
+
+ private static void addSqlComponent(Set<String> components, String uri) {
+ if (uri == null) {
+ return;
+ }
+ int colon = uri.indexOf(':');
+ if (colon > 0) {
+ String scheme = uri.substring(0, colon);
+ if (SQL_COMPONENTS.contains(scheme)) {
+ components.add(scheme);
+ }
+ }
+ }
+
+ private static Map<?, ?> section(Map<?, ?> root, String key) {
+ return root.get(key) instanceof Map<?, ?> m ? m : Map.of();
+ }
+
+ private static List<?> list(Object value) {
+ return value instanceof List<?> l ? l : List.of();
+ }
+
+ private static List<Map<?, ?>> objects(Object value) {
+ List<Map<?, ?>> answer = new ArrayList<>();
+ for (Object o : list(value)) {
+ if (o instanceof Map<?, ?> m) {
+ answer.add(m);
+ }
+ }
+ return answer;
+ }
+
+ private static String text(Object value) {
+ if (value == null) {
+ return null;
+ }
+ String s = value.toString();
+ return s.isBlank() ? null : s;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroup.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroup.java
new file mode 100644
index 000000000000..674fae926ac1
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroup.java
@@ -0,0 +1,42 @@
+/*
+ * 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;
+
+/**
+ * A group of runtime tools that is only worth its schemas when the
integration has what the tools read (CAMEL-24834): a
+ * small local model gets the core tools plus the groups of the selected
integration, see {@link ToolGroups}.
+ */
+public enum ToolGroup {
+
+ /** Datasources, SQL queries and the SQL trace. */
+ SQL("sql"),
+ /** OpenTelemetry spans, message tracing and Micrometer metrics. */
+ TRACING("tracing"),
+ /** Circuit breakers. */
+ RESILIENCE("resilience");
+
+ private final String id;
+
+ ToolGroup(String id) {
+ this.id = id;
+ }
+
+ /** The id clients see, e.g. {@code sql}. */
+ public String id() {
+ return id;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroups.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroups.java
new file mode 100644
index 000000000000..ea1983dd63f4
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroups.java
@@ -0,0 +1,178 @@
+/*
+ * 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.stream.Collectors;
+
+/**
+ * Picks the runtime tool groups for an integration from its {@link
AppFeatures} (CAMEL-24834). A small local model pays
+ * for every tool schema on every request, so the SQL, tracing and resilience
tools only load when the integration has a
+ * database, tracing or circuit breakers; each loaded group adds one line of
guidance that names what the integration
+ * has and which tool reads it. The tool names and the wording here are those
of the MCP server; the TUI maps the same
+ * groups to its own tools.
+ */
+public final class ToolGroups {
+
+ public static final String SQL_TOOL = "camel_runtime_sql";
+ public static final String DATASOURCES_TOOL = "camel_runtime_datasources";
+ public static final String SQL_TRACE_TOOL = "camel_runtime_sql_trace";
+ public static final String SPANS_TOOL = "camel_runtime_spans";
+ public static final String TRACE_TOOL = "camel_runtime_trace";
+ public static final String METRICS_TOOL = "camel_runtime_metrics";
+ public static final String CIRCUIT_BREAKERS_TOOL =
"camel_runtime_circuit_breakers";
+
+ /**
+ * A loaded group.
+ *
+ * @param group the group
+ * @param tools the tools it adds, in the order to offer them
+ * @param guidance one line for the model: what the integration has and
which tool reads it
+ */
+ public record Group(ToolGroup group, List<String> tools, String guidance) {
+
+ public Group {
+ tools = List.copyOf(tools);
+ }
+ }
+
+ /**
+ * The groups for an integration.
+ *
+ * @param groups the loaded groups, in {@link ToolGroup} order
+ * @param fingerprint stable for the same groups and datasources: a client
rebuilds its tool list only when it
+ * changes
+ */
+ public record Selection(List<Group> groups, String fingerprint) {
+
+ public Selection {
+ groups = List.copyOf(groups);
+ }
+
+ public List<ToolGroup> toolGroups() {
+ return groups.stream().map(Group::group).toList();
+ }
+
+ public boolean has(ToolGroup group) {
+ return groups.stream().anyMatch(g -> g.group() == group);
+ }
+
+ /** The tools of all loaded groups. */
+ public List<String> mcpTools() {
+ return groups.stream().flatMap(g ->
g.tools().stream()).distinct().toList();
+ }
+
+ /** The guidance lines of all loaded groups. */
+ public List<String> guidance() {
+ return groups.stream().map(Group::guidance).toList();
+ }
+ }
+
+ private ToolGroups() {
+ }
+
+ /** The groups an integration needs. */
+ public static Selection select(AppFeatures features) {
+ AppFeatures f = features != null ? features : AppFeatures.none();
+ List<Group> groups = new ArrayList<>();
+ for (ToolGroup group : groups(f)) {
+ switch (group) {
+ case SQL -> groups.add(new Group(
+ group,
+ List.of(SQL_TOOL, DATASOURCES_TOOL, SQL_TRACE_TOOL),
+ "SQL: " + describeSql(f) + ". Table names come from
the SQL trace (" + SQL_TRACE_TOOL
+ +
"); don't guess a schema."));
+ case TRACING -> {
+ List<String> tools = new ArrayList<>();
+ List<String> parts = new ArrayList<>();
+ if (f.openTelemetry()) {
+ tools.add(SPANS_TOOL);
+ parts.add("OpenTelemetry is on, " + SPANS_TOOL + " has
the spans per trace");
+ }
+ tools.add(TRACE_TOOL);
+ parts.add(f.messageTracing()
+ ? "message tracing is on, " + TRACE_TOOL + " dump
returns the traced messages"
+ : TRACE_TOOL + " enables message tracing");
+ if (f.micrometer()) {
+ tools.add(METRICS_TOOL);
+ parts.add(METRICS_TOOL + " has the Micrometer
metrics");
+ }
+ groups.add(new Group(group, tools, "Tracing: " +
String.join("; ", parts) + "."));
+ }
+ case RESILIENCE -> groups.add(new Group(
+ group, List.of(CIRCUIT_BREAKERS_TOOL),
+ describeBreakers(f) + ": " + CIRCUIT_BREAKERS_TOOL
+ + " shows state
(CLOSED/OPEN/HALF_OPEN) and failure rate;"
+ + " OPEN means
the fallback runs."));
+ }
+ }
+ return new Selection(groups, fingerprint(groups(f), f));
+ }
+
+ /** The groups the features call for, in {@link ToolGroup} order. */
+ public static List<ToolGroup> groups(AppFeatures features) {
+ List<ToolGroup> groups = new ArrayList<>();
+ if (features.sql()) {
+ groups.add(ToolGroup.SQL);
+ }
+ if (features.tracing()) {
+ groups.add(ToolGroup.TRACING);
+ }
+ if (features.circuitBreaker()) {
+ groups.add(ToolGroup.RESILIENCE);
+ }
+ return groups;
+ }
+
+ /**
+ * Sorted group ids and datasource names: the same integration gives the
same fingerprint, whatever order its status
+ * lists things in.
+ */
+ static String fingerprint(List<ToolGroup> groups, AppFeatures features) {
+ String ids =
groups.stream().map(ToolGroup::id).sorted().collect(Collectors.joining(","));
+ String ds =
features.dataSourceNames().stream().sorted().collect(Collectors.joining(","));
+ return ids + "|" + ds;
+ }
+
+ /**
+ * The datasources with their pool, and the SQL components the routes use,
e.g. {@code datasource(s) orders
+ * (HikariCP), used by sql endpoints}.
+ */
+ public static String describeSql(AppFeatures features) {
+ StringBuilder sb = new StringBuilder();
+ if (features.dataSources().isEmpty()) {
+ sb.append("no datasource listed yet");
+ } else {
+ sb.append("datasource(s) ").append(features.dataSources().stream()
+ .map(d -> d.poolType() != null &&
!"Unknown".equals(d.poolType())
+ ? d.name() + " (" + d.poolType() + ")" : d.name())
+ .collect(Collectors.joining(", ")));
+ }
+ if (!features.sqlComponents().isEmpty()) {
+ sb.append(", used by ").append(String.join(", ",
features.sqlComponents())).append(" endpoints");
+ }
+ return sb.toString();
+ }
+
+ /** {@code Circuit breakers in routes a, b}, or without the routes when
they are not known. */
+ public static String describeBreakers(AppFeatures features) {
+ return features.circuitBreakerRoutes().isEmpty()
+ ? "Circuit breakers"
+ : "Circuit breakers in routes " + String.join(", ",
features.circuitBreakerRoutes());
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
index 7b6fdad92057..ad36c29da355 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
@@ -441,6 +441,22 @@ public final class ToolRegistry {
return result.toJson();
}));
+ register(tool("get_tool_groups",
+ "Which runtime tool groups (sql, tracing, resilience) the
integration needs, from what it has: "
+ + "datasources and SQL endpoints,
OpenTelemetry, message tracing, Micrometer, "
+ + "circuit breakers. Returns the
tools of each group with one line of guidance, "
+ + "and a fingerprint that changes
only when the groups do.")
+ .param("name", "string", "Name or PID of the integration
(default: the selected or only one)", false)
+ .executor((ctx, args) -> {
+ String name = args.get("name");
+ if (name != null && !name.isBlank()) {
+ ctx.selectProcess(name);
+ } else {
+ ctx.selectSingleProcessIfNone();
+ }
+ return toolGroups(ctx, ctx.readFullStatus()).toJson();
+ }));
+
// Route control
registerRouteControlTool("stop_route", "stop",
"Gracefully stop a route. The route will finish processing
in-flight exchanges before stopping.");
@@ -455,6 +471,43 @@ public final class ToolRegistry {
.executor((ctx, args) -> ctx.stopApplication()));
}
+ /**
+ * The answer of get_tool_groups: the integration, the core tools every
client has, the groups its status calls for
+ * and the status keys that called for them.
+ */
+ static JsonObject toolGroups(ToolContext ctx, JsonObject status) {
+ AppFeatures features = AppFeatures.fromStatus(status);
+ ToolGroups.Selection selection = ToolGroups.select(features);
+ JsonObject answer = new JsonObject();
+ String app = null;
+ for (RuntimeHelper.ProcessInfo p : ctx.discoverProcesses()) {
+ if (p.pid() == ctx.pid()) {
+ app = p.name();
+ }
+ }
+ if (app == null && status != null && status.get("context") instanceof
Map<?, ?> context
+ && context.get("name") != null) {
+ app = context.get("name").toString();
+ }
+ answer.put("app", app);
+ answer.put("pid", ctx.pid());
+ answer.put("fingerprint", selection.fingerprint());
+ JsonArray core = new JsonArray();
+
authoringTools().stream().filter(ToolDescriptor::isCore).map(ToolDescriptor::name).forEach(core::add);
+ answer.put("core", core);
+ JsonArray groups = new JsonArray();
+ for (ToolGroups.Group g : selection.groups()) {
+ JsonObject group = new JsonObject();
+ group.put("id", g.group().id());
+ group.put("tools", new JsonArray(g.tools()));
+ group.put("guidance", g.guidance());
+ groups.add(group);
+ }
+ answer.put("groups", groups);
+ answer.put("signals", new JsonObject(features.signals()));
+ return answer;
+ }
+
private static void registerRouteControlTool(String name, String command,
String description) {
register(tool(name, description)
.param("routeId", "string", "The ID of the route", true)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeaturesTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeaturesTest.java
new file mode 100644
index 000000000000..c49f9293998c
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeaturesTest.java
@@ -0,0 +1,132 @@
+/*
+ * 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.List;
+
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AppFeaturesTest {
+
+ private static AppFeatures features(String json) throws Exception {
+ return AppFeatures.fromStatus((JsonObject)
Jsoner.deserialize(json.replace('\'', '"')));
+ }
+
+ @Test
+ void aDatasourceIsSql() throws Exception {
+ AppFeatures f = features("{'dataSources': {'dataSources': [{'name':
'orders', 'type': 'com.zaxxer.hikari"
+ + ".HikariDataSource', 'poolType':
'HikariCP'}]}}");
+ assertTrue(f.sql());
+ assertEquals(List.of(new AppFeatures.DataSource("orders",
"HikariCP")), f.dataSources());
+ assertEquals("orders", f.signals().get("dataSources"));
+ assertFalse(f.tracing());
+ assertFalse(f.circuitBreaker());
+ }
+
+ @Test
+ void anEmptyDataSourcesArrayIsNotSql() throws Exception {
+ AppFeatures f = features("{'dataSources': {'dataSources': []},
'sqlTrace': {}}");
+ assertFalse(f.sql());
+ assertTrue(f.signals().isEmpty());
+ assertTrue(ToolGroups.groups(f).isEmpty());
+ }
+
+ @Test
+ void sqlEndpointsAndTracedStatementsAreSql() throws Exception {
+ AppFeatures f = features("{'endpoints': {'endpoints': [{'uri':
'jdbc://default'}, {'uri': 'timer://tick'}]},"
+ + " 'routes': [{'routeId': 'r1', 'from':
'sql:select * from orders'}],"
+ + " 'sqlTrace': {'statements': [{'query':
'select * from orders',"
+ + " 'endpoint': 'sql-stored:proc'}]}}");
+ assertTrue(f.sql());
+ assertEquals(List.of("jdbc", "sql", "sql-stored"), f.sqlComponents());
+ assertTrue(f.sqlTraced());
+ assertTrue(f.dataSources().isEmpty());
+ }
+
+ @Test
+ void aCircuitBreakerProcessorNamesItsRoute() throws Exception {
+ AppFeatures f = features("{'routes': [{'routeId': 'pay', 'processors':
[{'routeId': 'pay', 'id': 'cb1',"
+ + " 'processor': 'circuitBreaker'},
{'processor': 'to', 'uri': 'http://x'}]},"
+ + " {'routeId': 'audit', 'processors':
[{'processor': 'log'}]}]}");
+ assertTrue(f.circuitBreaker());
+ assertEquals(List.of("pay"), f.circuitBreakerRoutes());
+ assertEquals("circuitBreaker in pay",
f.signals().get("routes.processors"));
+ }
+
+ @Test
+ void aResilience4jSectionWithBreakersIsResilience() throws Exception {
+ AppFeatures f = features("{'resilience4j': {'circuitBreakers':
[{'routeId': 'pay', 'id': 'cb1',"
+ + " 'state': 'OPEN'}]}, 'circuit-breaker':
{'circuitBreakers': []}}");
+ assertTrue(f.circuitBreaker());
+ assertEquals(List.of("pay"), f.circuitBreakerRoutes());
+ assertEquals("1 circuit breaker(s)", f.signals().get("resilience4j"));
+ assertFalse(f.signals().containsKey("circuit-breaker"), "an empty
section says nothing");
+ }
+
+ @Test
+ void theOpenTelemetryConsoleIsTracing() throws Exception {
+ AppFeatures f = features("{'devConsoles': ['context', 'route',
'opentelemetry']}");
+ assertTrue(f.openTelemetry());
+ assertTrue(f.tracing());
+ assertFalse(f.micrometer());
+ assertEquals("opentelemetry", f.signals().get("devConsoles"));
+ }
+
+ @Test
+ void enabledMessageTracingIsTracing() throws Exception {
+ assertTrue(features("{'trace': {'enabled': true}}").messageTracing());
+ assertFalse(features("{'trace': {'enabled': false, 'standby':
true}}").tracing(),
+ "the trace console is always there, only enabled tracing
counts");
+ }
+
+ @Test
+ void micrometerIsTracing() throws Exception {
+ assertTrue(features("{'micrometer': {'counters': []}}").micrometer());
+ assertTrue(features("{'devConsoles': ['micrometer']}").micrometer());
+ }
+
+ @Test
+ void anOldStatusWithoutConsolesOrProcessorsGivesFewerFeatures() throws
Exception {
+ // a status file of an older Camel: no devConsoles, routes without
processors, odd types
+ AppFeatures f = features("{'context': {'name': 'old'}, 'routes':
[{'routeId': 'r1', 'from': 'timer:x'}],"
+ + " 'dataSources': 'n/a', 'resilience4j': [],
'trace': 'off'}");
+ assertEquals(AppFeatures.none().dataSources(), f.dataSources());
+ assertFalse(f.sql());
+ assertFalse(f.tracing());
+ assertFalse(f.circuitBreaker());
+ assertFalse(AppFeatures.fromStatus(null).sql());
+ }
+
+ @Test
+ void mergeKeepsWhatEitherHad() throws Exception {
+ AppFeatures before = features("{'dataSources': {'dataSources':
[{'name': 'orders'}]},"
+ + " 'devConsoles': ['opentelemetry']}");
+ AppFeatures after = features("{'resilience4j': {'circuitBreakers':
[{'routeId': 'pay'}]}}");
+ AppFeatures merged = before.merge(after);
+ assertTrue(merged.sql());
+ assertTrue(merged.openTelemetry());
+ assertTrue(merged.circuitBreaker());
+ assertEquals(List.of("orders"), merged.dataSourceNames());
+ assertEquals(merged, merged.merge(AppFeatures.none()));
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroupsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroupsTest.java
new file mode 100644
index 000000000000..c6400367830b
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroupsTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ToolGroupsTest {
+
+ static AppFeatures everything() {
+ return new AppFeatures(
+ List.of(new AppFeatures.DataSource("orders", "HikariCP"), new
AppFeatures.DataSource("audit", null)),
+ List.of("sql"), true, true, List.of("pay", "ship"), true,
true, true, Map.of());
+ }
+
+ @Test
+ void nothingLoadsNoGroup() {
+ ToolGroups.Selection s = ToolGroups.select(AppFeatures.none());
+ assertTrue(s.groups().isEmpty());
+ assertTrue(s.mcpTools().isEmpty());
+ assertTrue(s.guidance().isEmpty());
+ assertEquals("|", s.fingerprint());
+ }
+
+ @Test
+ void eachGroupHasItsTools() {
+ ToolGroups.Selection s = ToolGroups.select(everything());
+ assertEquals(List.of(ToolGroup.SQL, ToolGroup.TRACING,
ToolGroup.RESILIENCE), s.toolGroups());
+ assertEquals(List.of("camel_runtime_sql", "camel_runtime_datasources",
"camel_runtime_sql_trace"),
+ s.groups().get(0).tools());
+ assertEquals(List.of("camel_runtime_spans", "camel_runtime_trace",
"camel_runtime_metrics"),
+ s.groups().get(1).tools());
+ assertEquals(List.of("camel_runtime_circuit_breakers"),
s.groups().get(2).tools());
+ }
+
+ @Test
+ void micrometerAloneLoadsTheTracingGroupWithTheMetrics() {
+ AppFeatures micrometer = new AppFeatures(
+ List.of(), List.of(), false, false, List.of(), false, false,
true, Map.of());
+ ToolGroups.Selection s = ToolGroups.select(micrometer);
+ assertEquals(List.of(ToolGroup.TRACING), s.toolGroups());
+ assertEquals(List.of("camel_runtime_trace", "camel_runtime_metrics"),
s.groups().get(0).tools());
+
assertTrue(s.groups().get(0).guidance().contains("camel_runtime_metrics has the
Micrometer metrics"));
+ }
+
+ @Test
+ void theNewerFeaturesWinWhenMerged() {
+ AppFeatures before = new AppFeatures(
+ List.of(new AppFeatures.DataSource("orders", null)),
List.of(), false, false, List.of(), false, false,
+ false, Map.of("dataSources", "orders"));
+ AppFeatures after = new AppFeatures(
+ List.of(new AppFeatures.DataSource("orders", "HikariCP")),
List.of(), false, false, List.of(), false, false,
+ false, Map.of("dataSources", "orders (HikariCP)"));
+ AppFeatures merged = before.merge(after);
+ assertEquals("HikariCP", merged.dataSources().get(0).poolType(), "the
pool type known after the pool started");
+ assertEquals("orders (HikariCP)", merged.signals().get("dataSources"));
+ }
+
+ @Test
+ void tracingOffersOnlyWhatIsOn() {
+ AppFeatures messageTracing = new AppFeatures(
+ List.of(), List.of(), false, false, List.of(), false, true,
false, Map.of());
+ ToolGroups.Selection s = ToolGroups.select(messageTracing);
+ assertEquals(List.of("camel_runtime_trace"), s.mcpTools());
+ assertTrue(s.guidance().get(0).contains("message tracing is on"),
s.guidance().get(0));
+ }
+
+ @Test
+ void theGuidanceNamesWhatTheIntegrationHas() {
+ List<String> guidance = ToolGroups.select(everything()).guidance();
+ assertEquals(3, guidance.size());
+ assertEquals("SQL: datasource(s) orders (HikariCP), audit, used by sql
endpoints. Table names come from the"
+ + " SQL trace (camel_runtime_sql_trace); don't guess a
schema.",
+ guidance.get(0));
+ assertTrue(guidance.get(1).contains("OpenTelemetry"), guidance.get(1));
+ assertTrue(guidance.get(2).startsWith("Circuit breakers in routes pay,
ship: camel_runtime_circuit_breakers"),
+ guidance.get(2));
+ for (String line : guidance) {
+ assertFalse(line.contains("\n"), "one line per group: " + line);
+ }
+ }
+
+ @Test
+ void theFingerprintIsStable() {
+ AppFeatures reordered = new AppFeatures(
+ List.of(new AppFeatures.DataSource("audit", null), new
AppFeatures.DataSource("orders", "HikariCP")),
+ List.of("sql"), true, true, List.of("ship", "pay"), true,
true, true, Map.of("x", "y"));
+ String fp = ToolGroups.select(everything()).fingerprint();
+ assertEquals("resilience,sql,tracing|audit,orders", fp);
+ assertEquals(fp, ToolGroups.select(reordered).fingerprint());
+ assertNotEquals(fp,
ToolGroups.select(AppFeatures.none()).fingerprint());
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java
index 671a88db8d40..36100dbb7f73 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java
@@ -23,6 +23,7 @@ import java.util.Set;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -168,6 +169,38 @@ class ToolRegistryTest {
() -> ToolRegistry.execute("execute_sql", ctx, Map.of()));
}
+ @Test
+ void theToolGroupsToolIsInternal() {
+ // the MCP servers export every camel_* tool by name; get_tool_groups
is reached through
+ // camel_runtime_tool_groups only
+ ToolDescriptor tool = ToolRegistry.findTool("get_tool_groups");
+ assertNotNull(tool);
+ assertTrue(tool.isReadOnly());
+ assertFalse(tool.isDeterministic());
+ assertFalse(ToolRegistry.authoringTools().contains(tool));
+ }
+
+ @Test
+ void toolGroupsAnswerFromTheStatus() throws Exception {
+ ToolContext ctx = new ToolContext();
+ ctx.selectProcess(99999);
+ String json = "{'context': {'name': 'shop'}, 'dataSources':
{'dataSources': [{'name': 'orders',"
+ + " 'poolType': 'HikariCP'}]}}";
+ JsonObject status = (JsonObject) Jsoner.deserialize(json.replace('\'',
'"'));
+ JsonObject answer = ToolRegistry.toolGroups(ctx, status);
+ assertEquals("shop", answer.get("app"));
+ assertEquals(99999L, answer.get("pid"));
+ assertEquals("sql|orders", answer.get("fingerprint"));
+ JsonArray groups = (JsonArray) answer.get("groups");
+ assertEquals(1, groups.size());
+ JsonObject sql = (JsonObject) groups.get(0);
+ assertEquals("sql", sql.get("id"));
+ assertTrue(((JsonArray)
sql.get("tools")).contains("camel_runtime_sql"));
+ assertTrue(sql.get("guidance").toString().contains("orders
(HikariCP)"));
+ assertTrue(((JsonArray)
answer.get("core")).contains("camel_get_errors"));
+ assertEquals("orders", ((JsonObject)
answer.get("signals")).get("dataSources"));
+ }
+
@Test
void circuitBreakerToolRequiresProcess() {
ToolContext ctx = new ToolContext();
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
index 01ebc047c158..f8fbe7bdbd48 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
@@ -286,6 +286,17 @@ public class RuntimeTools {
return delegateToRegistry("execute_sql", nameOrPid, args);
}
+ @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
+ description = """
+ Which runtime tool groups the Camel application needs, from
what it has: sql (datasources, \
+ SQL endpoints), tracing (OpenTelemetry, message tracing,
Micrometer) and resilience (circuit \
+ breakers). Returns the core tools, each group's tools with
one line of guidance, and a fingerprint \
+ that changes only when the groups do. A client for a small
model offers the core tools plus these.""")
+ public JsonObject camel_runtime_tool_groups(
+ @ToolArg(description = NAME_OR_PID_DESC, required = false) String
nameOrPid) {
+ return delegateToRegistry("get_tool_groups", nameOrPid, Map.of());
+ }
+
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
description = """
Get the datasources of the running Camel application with
their connection pool status: \
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
index 851060c78f88..ebd305f6313f 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
@@ -16,8 +16,10 @@
*/
package org.apache.camel.dsl.jbang.core.commands.mcp;
+import java.lang.reflect.Method;
import java.util.List;
+import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolCallException;
import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry;
import org.junit.jupiter.api.Test;
@@ -75,10 +77,20 @@ class RuntimeToolsTest {
.hasMessageContaining("query is required");
}
+ @Test
+ void theToolGroupsToolIsVisibleAtTheReadOnlyAccessLevel() throws Exception
{
+ // McpAccessFilter decides from the annotations: a read-only hint
keeps the tool for a read-only client
+ Method m =
RuntimeTools.class.getDeclaredMethod("camel_runtime_tool_groups", String.class);
+ Tool tool = m.getAnnotation(Tool.class);
+ assertThat(McpSecurityConfig.AccessLevel.READ_ONLY.permits(
+ tool.annotations().readOnlyHint(),
tool.annotations().destructiveHint())).isTrue();
+ }
+
@Test
void theNewWrappersDelegateToRegistryTools() {
// CAMEL-24867: every wrapper names a tool the shared registry has, so
a typo cannot hide until runtime
- for (String name : List.of("execute_sql", "get_datasources",
"get_sql_trace", "get_circuit_breakers", "get_metrics",
+ for (String name : List.of("execute_sql", "get_tool_groups",
"get_datasources", "get_sql_trace",
+ "get_circuit_breakers", "get_metrics",
"get_eip_stats", "get_spans", "get_startup_steps",
"get_route_analysis", "detect_config_drift")) {
assertThat(ToolRegistry.findTool(name)).as(name).isNotNull();
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index 78e0e8f7b457..b2e967781909 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -33,6 +33,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Callable;
@@ -75,6 +76,7 @@ import dev.tamboui.widgets.table.Table;
import dev.tamboui.widgets.table.TableState;
import org.apache.camel.dsl.jbang.core.commands.LlmClient;
import org.apache.camel.dsl.jbang.core.commands.ai.AnswerChecks;
+import org.apache.camel.dsl.jbang.core.commands.ai.AppFeatures;
import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
import org.apache.camel.dsl.jbang.core.common.Printer;
import org.apache.camel.util.json.JsonObject;
@@ -301,12 +303,31 @@ class AiPanel {
AcpAgentClient create(AiProviderSelector.AcpPreset preset, Path cwd)
throws IOException;
}
+ /** The selected integration as the tool groups see it; replaced in tests.
*/
+ interface AppStatusSource {
+ /** The selected pid, null when none. */
+ String selectedPid();
+
+ /** How often it reloaded its routes. */
+ int reloadCount();
+
+ /** What it has, read from its status. */
+ AppFeatures features();
+ }
+
// MCP facade for TUI tool access from the AI panel
private McpFacade mcpFacade;
// /write: confirm (dialog per write), auto (the model may skip it with
confirm=false) or live (the edit is replayed
// in the source editor and the user saves or discards it)
private McpFacade.WriteMode writeMode = McpFacade.WriteMode.CONFIRM;
private TuiToolRegistry toolRegistry;
+ // CAMEL-24834: the tool groups (SQL, tracing, resilience) of the selected
integration that the core set gets, see
+ // refreshToolGroups()
+ private volatile AppStatusSource appStatusSource;
+ private volatile String toolGroupsPid;
+ private volatile int toolGroupsReloads = -1;
+ private volatile AppFeatures toolGroupsFeatures = AppFeatures.none();
+ private volatile TuiToolGroups.Selection toolGroups =
TuiToolGroups.Selection.none();
private boolean mcpServerActive;
private int mcpServerPort;
@@ -1561,6 +1582,7 @@ class AiPanel {
if (!testingClientInjected) {
toolMode = normalizeToolMode(TuiSettings.load().getAiTools());
}
+ refreshToolGroups();
tools = buildTuiToolDefinitions();
String systemPrompt = buildSystemPrompt();
@@ -3328,9 +3350,63 @@ class AiPanel {
sb.append("\nThe TUI MCP server is available at http://localhost:")
.append(mcpServerPort).append("/mcp for external AI
agents.");
}
+ // last, so the prefix above stays cached when the groups change
+ if (useCoreTools() && !toolGroups.guidance().isEmpty()) {
+ sb.append(mcpServerActive ? "\n" : "").append("\nThe selected
integration:\n");
+ toolGroups.guidance().forEach(line -> sb.append("-
").append(line).append('\n'));
+ }
return sb.toString();
}
+ /**
+ * Loads the tool groups of the selected integration for the core set
(CAMEL-24834): the SQL tools when it has a
+ * database, the guidance for its tracing and circuit breakers. Read again
only when another integration is selected
+ * or the selected one reloaded (the groups then only grow: what it had
before still counts), so the tools and the
+ * prompt stay the same from question to question and a local model's
prompt cache keeps working. While an
+ * integration has no groups yet its status is read again, since one that
just started may not have written it
+ * completely. The full set is not affected.
+ */
+ private void refreshToolGroups() {
+ if (!useCoreTools()) {
+ return;
+ }
+ AppStatusSource source = appStatusSource != null ? appStatusSource :
facadeStatusSource();
+ String pid = source != null ? source.selectedPid() : null;
+ int reloads = source != null ? source.reloadCount() : 0;
+ boolean samePid = Objects.equals(pid, toolGroupsPid);
+ if (samePid && reloads == toolGroupsReloads &&
!toolGroups.groups().isEmpty()) {
+ return;
+ }
+ AppFeatures read = pid != null ? source.features() :
AppFeatures.none();
+ toolGroupsFeatures = samePid ? toolGroupsFeatures.merge(read) : read;
+ toolGroupsPid = pid;
+ toolGroupsReloads = reloads;
+ toolGroups = TuiToolGroups.select(toolGroupsFeatures);
+ }
+
+ private AppStatusSource facadeStatusSource() {
+ McpFacade facade = mcpFacade;
+ if (facade == null) {
+ return null;
+ }
+ return new AppStatusSource() {
+ @Override
+ public String selectedPid() {
+ return facade.getSelectedPid();
+ }
+
+ @Override
+ public int reloadCount() {
+ return facade.getSelectedReloadCount();
+ }
+
+ @Override
+ public AppFeatures features() {
+ return AppFeatures.fromStatus(facade.readSelectedStatus());
+ }
+ };
+ }
+
/**
* Prefixes the question with the integration the user is looking at. This
used to live in the system prompt, but
* there it invalidated the model's cached prompt prefix every time the
selection changed.
@@ -3368,11 +3444,18 @@ class AiPanel {
return "no tools available";
}
int total = toolRegistry.getToolDefinitions().size();
- int active = useCoreTools() ?
toolRegistry.getCoreToolDefinitions().size() : total;
+ int active = useCoreTools() ?
toolRegistry.getCoreToolDefinitions(toolGroups.tools()).size() : total;
String mode = toolMode == null ? TOOL_MODE_AUTO : toolMode;
String detail = TOOL_MODE_AUTO.equals(mode)
? (useCoreTools() ? " (local provider)" : " (hosted
provider)") : "";
- return (useCoreTools() ? "core" : "full") + " (" + active + " of " +
total + " tools), mode " + mode + detail;
+ String groups = "";
+ if (useCoreTools()) {
+ groups = toolGroups.groups().isEmpty()
+ ? "; groups: none loaded"
+ : "; groups: " + toolGroups.groupIds() + " (from the
selected integration)";
+ }
+ return (useCoreTools() ? "core" : "full") + " (" + active + " of " +
total + " tools), mode " + mode + detail
+ + groups;
}
private List<LlmClient.ToolDef> buildTuiToolDefinitions() {
@@ -3380,8 +3463,8 @@ class AiPanel {
return List.of();
}
List<LlmClient.ToolDef> defs = new ArrayList<>();
- List<TuiToolRegistry.ToolDef> source
- = useCoreTools() ? toolRegistry.getCoreToolDefinitions() :
toolRegistry.getToolDefinitions();
+ List<TuiToolRegistry.ToolDef> source = useCoreTools()
+ ? toolRegistry.getCoreToolDefinitions(toolGroups.tools()) :
toolRegistry.getToolDefinitions();
for (TuiToolRegistry.ToolDef td : source) {
defs.add(new LlmClient.ToolDef(td.name(), td.description(),
td.inputSchema()));
}
@@ -3975,6 +4058,18 @@ class AiPanel {
return buildSystemPrompt();
}
+ void setAppStatusSourceForTesting(AppStatusSource source) {
+ this.appStatusSource = source;
+ }
+
+ void refreshToolGroupsForTesting() {
+ refreshToolGroups();
+ }
+
+ TuiToolGroups.Selection toolGroupsForTesting() {
+ return toolGroups;
+ }
+
List<LlmClient.ToolDef> toolDefinitionsForTesting() {
return buildTuiToolDefinitions();
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index f6915ee3923d..23d12664c4f7 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -344,6 +344,28 @@ class McpFacade {
return info != null ? info.name : null;
}
+ /** How often the selected integration has reloaded its routes, 0 when
nothing is selected. */
+ int getSelectedReloadCount() {
+ if (ctx == null) {
+ return 0;
+ }
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ return info != null ? info.reloaded : 0;
+ }
+
+ /** The status document of the selected integration, null when nothing is
selected or it has none yet. */
+ JsonObject readSelectedStatus() {
+ if (ctx == null || ctx.selectedPid == null) {
+ return null;
+ }
+ try {
+ return RuntimeHelper.readStatus(Long.parseLong(ctx.selectedPid));
+ } catch (RuntimeException e) {
+ // not a pid, or a status that cannot be read: no tool groups, the
core set is still offered
+ return null;
+ }
+ }
+
String getSelectedCamelVersion() {
if (ctx == null) {
return null;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
new file mode 100644
index 000000000000..448841972cff
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
@@ -0,0 +1,106 @@
+/*
+ * 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.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.camel.dsl.jbang.core.commands.ai.AppFeatures;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolGroup;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolGroups;
+
+/**
+ * The AI panel's side of {@link ToolGroups} (CAMEL-24834): the tui_* tools
each group adds to the core set, and the
+ * guidance line in the panel's words. Only SQL needs tools of its own; the
spans, the traced messages, the metrics and
+ * the circuit breakers are read with core tools (tui_get_spans,
tui_get_history, tui_get_table), so those groups only
+ * add the line that tells a small model where to look.
+ */
+final class TuiToolGroups {
+
+ static final String SQL_TOOL = "tui_execute_sql";
+ static final String UPDATE_ROW_TOOL = "tui_update_row";
+
+ /**
+ * The groups of an integration as the panel uses them.
+ *
+ * @param groups the loaded groups
+ * @param tools the tools the groups add to the core set
+ * @param guidance one line per group, appended to the system prompt
+ */
+ record Selection(List<ToolGroup> groups, List<String> tools, List<String>
guidance) {
+
+ Selection {
+ groups = List.copyOf(groups);
+ tools = List.copyOf(tools);
+ guidance = List.copyOf(guidance);
+ }
+
+ static Selection none() {
+ return new Selection(List.of(), List.of(), List.of());
+ }
+
+ String groupIds() {
+ return
groups.stream().map(ToolGroup::id).collect(Collectors.joining(", "));
+ }
+ }
+
+ private TuiToolGroups() {
+ }
+
+ static Selection select(AppFeatures features) {
+ AppFeatures f = features != null ? features : AppFeatures.none();
+ List<ToolGroup> groups = ToolGroups.groups(f);
+ List<String> tools = new ArrayList<>();
+ List<String> guidance = new ArrayList<>();
+ for (ToolGroup group : groups) {
+ tools.addAll(tools(group));
+ guidance.add(guidance(group, f));
+ }
+ return new Selection(groups, tools, guidance);
+ }
+
+ static List<String> tools(ToolGroup group) {
+ if (group == ToolGroup.SQL) {
+ return List.of(SQL_TOOL, UPDATE_ROW_TOOL);
+ }
+ return List.of();
+ }
+
+ static String guidance(ToolGroup group, AppFeatures f) {
+ return switch (group) {
+ case SQL -> "SQL: " + ToolGroups.describeSql(f)
+ + ". Table names come from the SQL trace
(tui_get_table tab 'SQL Trace'); don't guess a schema.";
+ case TRACING -> {
+ List<String> parts = new ArrayList<>();
+ if (f.openTelemetry()) {
+ parts.add("OpenTelemetry is on, tui_get_spans has the
spans per trace");
+ }
+ if (f.messageTracing()) {
+ parts.add("message tracing is on, tui_get_history has the
traced messages");
+ }
+ if (f.micrometer()) {
+ parts.add("tui_get_table tab 'Metrics' has the Micrometer
metrics");
+ }
+ yield "Tracing: " + String.join("; ", parts) + ".";
+ }
+ case RESILIENCE -> ToolGroups.describeBreakers(f)
+ + ": tui_get_table tab 'Circuit Breaker' shows
state (CLOSED/OPEN/HALF_OPEN) and failure"
+ + " rate; OPEN means the fallback runs.";
+ };
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 2fbe310fa854..89b2af9b9e4a 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
@@ -264,6 +265,16 @@ class TuiToolRegistry {
return getToolDefinitions().stream().filter(t ->
CORE_TOOLS.contains(t.name())).toList();
}
+ /**
+ * Returns the {@link #CORE_TOOLS} definitions plus the given ones (the
tools of the integration's tool groups, see
+ * {@link TuiToolGroups}), in registry order.
+ */
+ List<ToolDef> getCoreToolDefinitions(Collection<String> extra) {
+ return getToolDefinitions().stream()
+ .filter(t -> CORE_TOOLS.contains(t.name()) ||
extra.contains(t.name()))
+ .toList();
+ }
+
/**
* Executes a tool by name, returns result string.
*/
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
index 39fa18662189..fca8515e5783 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
@@ -17,6 +17,10 @@
package org.apache.camel.dsl.jbang.core.commands.tui;
import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
import org.apache.camel.dsl.jbang.core.commands.LlmClient;
import org.apache.camel.util.json.JsonObject;
@@ -55,6 +59,11 @@ class AiPanelPromptBudgetTest {
// raised from 9450 for camel_project_overview and
camel_save_project_summary (CAMEL-25143), measured ~9750:
// full mode only (hosted models), and the panel's own /overview sends no
tools at all
static final int FULL_BUDGET_TOKENS = 9_850;
+ /** Measured ~5.2k tokens for 26 tools: the core set (~4.7k) plus every
tool group (CAMEL-24834). */
+ // the SQL group adds tui_execute_sql and tui_update_row (~385 tokens),
each group one guidance line in the prompt
+ // (~130 for all three); an integration rarely has all three, and the
groups only load for the integration that
+ // needs them
+ static final int CORE_WITH_GROUPS_BUDGET_TOKENS = 5_500;
record Prefix(String mode, int tools, long promptChars, long toolChars) {
@@ -99,10 +108,23 @@ class AiPanelPromptBudgetTest {
AiPanel panel = new AiPanel();
panel.setToolRegistryForTesting(new TuiToolRegistry(null));
panel.setToolModeForTesting(mode);
+ return measure(mode, panel);
+ }
+
+ static Prefix measure(String mode, AiPanel panel) {
List<LlmClient.ToolDef> defs = panel.toolDefinitionsForTesting();
return new Prefix(mode, defs.size(),
panel.systemPromptForTesting().length(), wireChars(defs));
}
+ /** A core panel with every tool group loaded: datasources, OpenTelemetry,
tracing, Micrometer, circuit breakers. */
+ static AiPanel coreWithAllGroups() {
+ AiPanelToolGroupsTest.FakeApp app = new
AiPanelToolGroupsTest.FakeApp();
+ app.features = AiPanelToolGroupsTest.EVERYTHING;
+ AiPanel panel = AiPanelToolGroupsTest.panel(AiPanel.TOOL_MODE_CORE,
app);
+ panel.refreshToolGroupsForTesting();
+ return panel;
+ }
+
@Test
void corePrefixStaysWithinBudget() {
Prefix core = measure(AiPanel.TOOL_MODE_CORE);
@@ -112,6 +134,17 @@ class AiPanelPromptBudgetTest {
"core prefix grew to ~" + core.totalTokens() + " tokens,
budget " + CORE_BUDGET_TOKENS + ": " + core);
}
+ @Test
+ void coreWithAllGroupsPrefixStaysWithinBudget() {
+ Prefix groups = measure("core+groups", coreWithAllGroups());
+ System.out.println("AI panel static prefix: " + groups);
+
+ assertTrue(groups.totalTokens() <= CORE_WITH_GROUPS_BUDGET_TOKENS,
+ "core prefix with all groups grew to ~" + groups.totalTokens()
+ " tokens, budget "
+ +
CORE_WITH_GROUPS_BUDGET_TOKENS + ": "
+ +
groups);
+ }
+
@Test
void fullPrefixStaysWithinBudget() {
Prefix full = measure(AiPanel.TOOL_MODE_FULL);
@@ -154,5 +187,20 @@ class AiPanelPromptBudgetTest {
for (String prompt : List.of(core, full)) {
assertTrue(prompt.contains("camel_write_file"), "write files with
the tool");
}
+
+ // CAMEL-24834: the guidance of the tool groups only names tools the
model is given
+ AiPanel groups = coreWithAllGroups();
+ String prompt = groups.systemPromptForTesting();
+ int start = prompt.indexOf("The selected integration:");
+ assertTrue(start > 0, "the guidance is appended at the end");
+ Set<String> tools =
groups.toolDefinitionsForTesting().stream().map(LlmClient.ToolDef::name)
+ .collect(Collectors.toSet());
+ Matcher m =
Pattern.compile("\\b(?:tui|camel)_[a-z_]+").matcher(prompt.substring(start));
+ int named = 0;
+ while (m.find()) {
+ named++;
+ assertTrue(tools.contains(m.group()), m.group() + " is named in
the guidance but not in the set");
+ }
+ assertTrue(named >= 4, "the guidance names the tools to use");
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
new file mode 100644
index 000000000000..53fb1bf07691
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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.tui;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.dsl.jbang.core.commands.LlmClient;
+import org.apache.camel.dsl.jbang.core.commands.ai.AppFeatures;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolGroup;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * CAMEL-24834: the AI panel loads the tool groups of the selected integration
into the core set, and reads them again
+ * only when the integration or its routes change.
+ */
+class AiPanelToolGroupsTest {
+
+ static final AppFeatures SQL = new AppFeatures(
+ List.of(new AppFeatures.DataSource("orders", "HikariCP")),
List.of("sql"), true, false, List.of(),
+ false, false, false, Map.of());
+ static final AppFeatures BREAKERS = new AppFeatures(
+ List.of(), List.of(), false, true, List.of("pay"), false, false,
false, Map.of());
+ static final AppFeatures OTEL = new AppFeatures(
+ List.of(), List.of(), false, false, List.of(), true, false, false,
Map.of());
+ static final AppFeatures EVERYTHING = SQL.merge(BREAKERS).merge(new
AppFeatures(
+ List.of(), List.of(), false, false, List.of(), true, true, true,
Map.of()));
+
+ /** A selected integration whose pid, reload count and features a test
changes, counting the status reads. */
+ static final class FakeApp implements AiPanel.AppStatusSource {
+ String pid = "100";
+ int reloads;
+ AppFeatures features = SQL;
+ int reads;
+
+ @Override
+ public String selectedPid() {
+ return pid;
+ }
+
+ @Override
+ public int reloadCount() {
+ return reloads;
+ }
+
+ @Override
+ public AppFeatures features() {
+ reads++;
+ return features;
+ }
+ }
+
+ static AiPanel panel(String mode, FakeApp app) {
+ AiPanel panel = new AiPanel();
+ panel.setToolRegistryForTesting(new TuiToolRegistry(null));
+ panel.setToolModeForTesting(mode);
+ panel.setAppStatusSourceForTesting(app);
+ return panel;
+ }
+
+ static List<String> toolNames(AiPanel panel) {
+ return
panel.toolDefinitionsForTesting().stream().map(LlmClient.ToolDef::name).toList();
+ }
+
+ @Test
+ void theSqlGroupAddsTheSqlTools() {
+ AiPanel panel = panel(AiPanel.TOOL_MODE_CORE, new FakeApp());
+ assertFalse(toolNames(panel).contains("tui_execute_sql"), "no group
before the first question");
+
+ panel.refreshToolGroupsForTesting();
+ assertEquals(List.of(ToolGroup.SQL),
panel.toolGroupsForTesting().groups());
+ assertTrue(toolNames(panel).containsAll(List.of("tui_execute_sql",
"tui_update_row")));
+ assertTrue(panel.systemPromptForTesting().contains(
+ "- SQL: datasource(s) orders (HikariCP), used by sql
endpoints. Table names come from the SQL trace"
+ + " (tui_get_table
tab 'SQL Trace'); don't guess a schema.\n"));
+ assertTrue(panel.describeToolModeForTesting().contains("groups: sql
(from the selected integration)"),
+ panel.describeToolModeForTesting());
+ }
+
+ @Test
+ void anotherIntegrationGetsItsOwnGroups() {
+ FakeApp app = new FakeApp();
+ AiPanel panel = panel(AiPanel.TOOL_MODE_CORE, app);
+ panel.refreshToolGroupsForTesting();
+ assertEquals(List.of(ToolGroup.SQL),
panel.toolGroupsForTesting().groups());
+
+ app.pid = "200";
+ app.features = OTEL;
+ panel.refreshToolGroupsForTesting();
+ assertEquals(2, app.reads);
+ assertEquals(List.of(ToolGroup.TRACING),
panel.toolGroupsForTesting().groups(), "no union across integrations");
+ assertFalse(toolNames(panel).contains("tui_execute_sql"));
+ assertTrue(panel.systemPromptForTesting().contains("tui_get_spans has
the spans"));
+ }
+
+ @Test
+ void aReloadAddsToTheGroups() {
+ FakeApp app = new FakeApp();
+ AiPanel panel = panel(AiPanel.TOOL_MODE_CORE, app);
+ panel.refreshToolGroupsForTesting();
+
+ app.reloads = 1;
+ app.features = BREAKERS;
+ panel.refreshToolGroupsForTesting();
+ assertEquals(2, app.reads);
+ assertEquals(List.of(ToolGroup.SQL, ToolGroup.RESILIENCE),
panel.toolGroupsForTesting().groups(),
+ "what it had before the reload still counts");
+ assertTrue(panel.systemPromptForTesting().contains("Circuit breakers
in routes pay: tui_get_table tab"));
+ }
+
+ @Test
+ void theGroupsAreCachedOtherwise() {
+ FakeApp app = new FakeApp();
+ AiPanel panel = panel(AiPanel.TOOL_MODE_CORE, app);
+ panel.refreshToolGroupsForTesting();
+ String prompt = panel.systemPromptForTesting();
+ List<LlmClient.ToolDef> tools = panel.toolDefinitionsForTesting();
+
+ app.features = EVERYTHING;
+ for (int i = 0; i < 3; i++) {
+ panel.refreshToolGroupsForTesting();
+ }
+ assertEquals(1, app.reads, "same integration, no reload: the status is
not read again");
+ assertEquals(prompt, panel.systemPromptForTesting(), "the prompt stays
byte-identical");
+ assertEquals(tools, panel.toolDefinitionsForTesting());
+ }
+
+ @Test
+ void anIntegrationWithoutGroupsIsReadAgain() {
+ // one that just started may not have written its status completely yet
+ FakeApp app = new FakeApp();
+ app.features = AppFeatures.none();
+ AiPanel panel = panel(AiPanel.TOOL_MODE_CORE, app);
+ panel.refreshToolGroupsForTesting();
+ assertTrue(panel.toolGroupsForTesting().groups().isEmpty());
+ assertTrue(panel.describeToolModeForTesting().contains("groups: none
loaded"));
+
+ app.features = SQL;
+ panel.refreshToolGroupsForTesting();
+ assertEquals(List.of(ToolGroup.SQL),
panel.toolGroupsForTesting().groups());
+ }
+
+ @Test
+ void theFullSetIsUnchanged() {
+ FakeApp app = new FakeApp();
+ app.features = EVERYTHING;
+ AiPanel plain = panel(AiPanel.TOOL_MODE_FULL, new FakeApp());
+ AiPanel panel = panel(AiPanel.TOOL_MODE_FULL, app);
+ String before = panel.systemPromptForTesting();
+ panel.refreshToolGroupsForTesting();
+
+ assertEquals(0, app.reads, "the full set does not need the status");
+ assertEquals(before, panel.systemPromptForTesting());
+ assertFalse(panel.systemPromptForTesting().contains("The selected
integration"));
+ assertEquals(toolNames(plain), toolNames(panel));
+ assertTrue(toolNames(panel).contains("tui_update_row"));
+ }
+}