This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-24834 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 219d9dbeb6ecd2cbe73a6b80769026d17289fa33 Author: Claus Ibsen <[email protected]> AuthorDate: Sat Oct 3 09:23:56 2026 +0200 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 --- .../modules/ROOT/pages/camel-jbang-mcp.adoc | 45 ++++ .../modules/ROOT/pages/camel-jbang-tui-ai.adoc | 2 +- .../ROOT/pages/camel-jbang-tui-local-models.adoc | 29 +++ .../ROOT/pages/camel-jbang-tui-settings.adoc | 11 +- .../dsl/jbang/core/commands/ai/AppFeatures.java | 283 +++++++++++++++++++++ .../jbang/core/commands/ai/SqlReadOnlyGuard.java | 201 +++++++++++++++ .../dsl/jbang/core/commands/ai/ToolGroup.java | 42 +++ .../dsl/jbang/core/commands/ai/ToolGroups.java | 190 ++++++++++++++ .../dsl/jbang/core/commands/ai/ToolRegistry.java | 107 +++++++- .../jbang/core/commands/ai/AppFeaturesTest.java | 132 ++++++++++ .../core/commands/ai/SqlReadOnlyGuardTest.java | 101 ++++++++ .../dsl/jbang/core/commands/ai/ToolGroupsTest.java | 102 ++++++++ .../jbang/core/commands/ai/ToolRegistryTest.java | 52 ++++ .../dsl/jbang/core/commands/mcp/RuntimeTools.java | 44 ++++ .../jbang/core/commands/mcp/RuntimeToolsTest.java | 41 ++- .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 147 ++++++++++- .../dsl/jbang/core/commands/tui/McpFacade.java | 21 ++ .../dsl/jbang/core/commands/tui/TuiSettings.java | 21 ++ .../dsl/jbang/core/commands/tui/TuiToolGroups.java | 110 ++++++++ .../jbang/core/commands/tui/TuiToolRegistry.java | 11 + .../core/commands/tui/AiPanelPromptBudgetTest.java | 50 ++++ .../core/commands/tui/AiPanelToolGroupsTest.java | 218 ++++++++++++++++ 22 files changed, 1940 insertions(+), 20 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 e93680c9af0b..c9163983addc 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc @@ -793,6 +793,16 @@ 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_sql_query` +| Run a read-only SQL query (one SELECT, WITH, VALUES, SHOW, EXPLAIN or DESCRIBE statement) against a DataSource + of the running application. A statement that writes (INSERT, UPDATE, DELETE, MERGE, DDL, SELECT ... INTO, + FOR UPDATE, EXPLAIN ANALYZE, a second statement) is refused before anything runs. Marked read-only, so it stays + available at the `read-only` access level where `camel_runtime_sql` is hidden. + +| `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. @@ -822,6 +832,41 @@ 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_query`, `camel_runtime_datasources`, `camel_runtime_sql_trace`; with `sqlWrites=true` also + `camel_runtime_sql` + +| `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). Read-only: SELECT only (camel_runtime_sql_query). Table names come from the +SQL trace (camel_runtime_sql_trace); don't guess a schema.`), `sqlReadOnly`, and the `signals` in the status that +loaded each group. Its `fingerprint` stays the same as long as the groups, the datasources and the SQL mode 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..f83fbe216eef 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 and whether SQL is read-only, for example `core (25 of 60 tools), mode auto (local provider); groups: sql, resilience (from the selected integration); SQL read-only`, see xref:camel-jbang-tui-local-models.adoc#_tool_gro [...] | `/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..a9266c3519e1 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,35 @@ 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` (read-only), 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 330 tokens. + +SQL is read-only for a local model: `tui_execute_sql` runs a SELECT (or WITH, SHOW, EXPLAIN, DESCRIBE) +statement and refuses anything that writes, and `tui_update_row` is not offered. Set `camel.tui.ai.sqlWrites` +to `true` to allow writes (see xref:camel-jbang-tui-settings.adoc[Settings]). The full tool set (`/tools full` +and hosted providers) is not affected by the groups and is not limited to reading. `/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/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc index d786640d8fc8..b7c6e530c286 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc @@ -153,7 +153,7 @@ Settings are stored under `camel.tui.*` keys (`camel.tui.theme`, `camel.tui.star `camel.tui.selectTab`, `camel.tui.confirmActions`, `camel.tui.defaultFolder`, `camel.tui.panelPosition`, `camel.tui.panelSpace`, `camel.tui.shell.history`, `camel.tui.ai.provider`, `camel.tui.ai.model`, `camel.tui.ai.url`, -`camel.tui.ai.tools`, `camel.tui.ai.overview`, `camel.tui.ai.promptHistory`) in the Camel CLI configuration file. Each key is read from and +`camel.tui.ai.tools`, `camel.tui.ai.overview`, `camel.tui.ai.promptHistory`, `camel.tui.ai.sqlWrites`) in the Camel CLI configuration file. Each key is read from and written back to the file where it currently lives: a key present in the local `./camel-cli.properties` is treated as a project-level override and stays local, while every other key defaults to the global `~/.camel-cli.properties`. This means a project can @@ -161,6 +161,15 @@ deliberately pin a starting tab in its local config without redirecting your per into the project file. See xref:camel-jbang-configuration.adoc[Configuration] for details on the global and local files. +=== SQL writes from the AI panel + +`camel.tui.ai.sqlWrites` has no row in the dialog; set it in `.camel-cli.properties` (or with +`camel config set camel.tui.ai.sqlWrites=true`). With the core tool set, which local models get, the AI panel +only reads the integration's database: `tui_execute_sql` refuses a statement that writes and `tui_update_row` +is not offered. Set the key to `true` to let a local model write as well. The default is `false`; the full tool +set is not limited. See +xref:camel-jbang-tui-local-models.adoc#_tool_groups_from_the_selected_integration[Tool groups from the selected integration]. + === Input history The embedded shell (*F6*) and AI prompt (*F8*) keep a recall list for the command line and prompt 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..8a1f0cd054b0 --- /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,283 @@ +/* + * 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. + */ + 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.putIfAbsent(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::putIfAbsent); + 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/SqlReadOnlyGuard.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SqlReadOnlyGuard.java new file mode 100644 index 000000000000..916e97634730 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SqlReadOnlyGuard.java @@ -0,0 +1,201 @@ +/* + * 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.Locale; +import java.util.Set; + +/** + * Tells whether a SQL statement only reads (CAMEL-24834), so a small local model can query the integration's database + * without being able to change it. A check of the statement's words, not a SQL parser: comments and quoted literals are + * removed first (a {@code 'DELETE'} or {@code ';'} inside a string does not count), then the statement must be a single + * one starting with SELECT, WITH, VALUES, TABLE, SHOW, EXPLAIN (not EXPLAIN ANALYZE, which runs the statement), + * DESCRIBE or DESC, and must not write anywhere inside: no SELECT ... INTO, no FOR UPDATE / FOR SHARE locks, no INSERT, + * UPDATE, DELETE or MERGE in a CTE. It errs on the side of refusing; the user can enable SQL writes. + */ +public final class SqlReadOnlyGuard { + + static final String ALLOWED = "only a SELECT (or WITH, SHOW, EXPLAIN, DESCRIBE) statement is allowed;" + + " ask the user to enable SQL writes"; + + private static final Set<String> READ_KEYWORDS + = Set.of("SELECT", "WITH", "VALUES", "TABLE", "SHOW", "EXPLAIN", "DESCRIBE", "DESC"); + + /** Words that change data, schema or grants, or run code, wherever they appear outside literals. */ + private static final Set<String> WRITE_KEYWORDS = Set.of( + "INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "TRUNCATE", "DROP", "ALTER", "CREATE", + "GRANT", "REVOKE", "CALL", "EXEC", "EXECUTE"); + + /** What follows FOR in a locking read: FOR UPDATE, FOR SHARE, FOR NO KEY UPDATE, FOR KEY SHARE. */ + private static final Set<String> LOCK_MODES = Set.of("UPDATE", "SHARE", "NO", "KEY"); + + private SqlReadOnlyGuard() { + } + + /** + * Checks a statement. + * + * @return null when the statement only reads, otherwise the message to answer with + */ + public static String check(String sql) { + if (sql == null || sql.isBlank()) { + return "read-only: the statement is empty; " + ALLOWED; + } + // MySQL reads quotes and comments differently from standard SQL (a backslash escapes a quote, # starts a + // comment, /*! ... */ is code); where two readings disagree on what is a literal or a comment, a statement + // could hide in the difference, so it must pass both + String answer = check(sql, false); + return answer != null ? answer : check(sql, true); + } + + private static String check(String sql, boolean mysql) { + String code = stripCommentsAndLiterals(sql, mysql); + if (code == null) { + return "read-only: a comment or quoted literal that is not terminated, or a nested or executable comment; " + + ALLOWED; + } + code = code.strip(); + while (code.endsWith(";")) { + code = code.substring(0, code.length() - 1).strip(); + } + if (code.indexOf(';') >= 0) { + return "read-only: one statement at a time; " + ALLOWED; + } + List<String> words = words(code); + if (words.isEmpty()) { + return "read-only: the statement is empty; " + ALLOWED; + } + String first = words.get(0); + if (!READ_KEYWORDS.contains(first)) { + return "read-only: " + first + " is not a read; " + ALLOWED; + } + // SHOW CREATE TABLE and DESCRIBE only read the catalog + boolean catalog = "SHOW".equals(first) || "DESCRIBE".equals(first) || "DESC".equals(first); + for (int i = 0; i < words.size(); i++) { + String w = words.get(i); + String next = i + 1 < words.size() ? words.get(i + 1) : ""; + if (!catalog && WRITE_KEYWORDS.contains(w)) { + return "read-only: the statement contains " + w + "; " + ALLOWED; + } + if ("INTO".equals(w)) { + return "read-only: SELECT ... INTO writes a table; " + ALLOWED; + } + if ("FOR".equals(w) && LOCK_MODES.contains(next) || "LOCK".equals(w) && "IN".equals(next)) { + return "read-only: the statement locks rows; " + ALLOWED; + } + if ("EXPLAIN".equals(first) && ("ANALYZE".equals(w) || "ANALYSE".equals(w))) { + return "read-only: EXPLAIN ANALYZE runs the statement; " + ALLOWED; + } + } + return null; + } + + /** + * The statement with comments and quoted literals or identifiers replaced by a space, read the standard way or the + * MySQL way; null when one is not terminated, or for a comment that a database could read differently (nested, or + * MySQL's executable {@code /*!}). + */ + static String stripCommentsAndLiterals(String sql, boolean mysql) { + StringBuilder sb = new StringBuilder(sql.length()); + int i = 0; + int n = sql.length(); + while (i < n) { + char c = sql.charAt(i); + char next = i + 1 < n ? sql.charAt(i + 1) : 0; + if (c == '-' && next == '-' && (!mysql || i + 2 >= n || Character.isWhitespace(sql.charAt(i + 2))) + || mysql && c == '#') { + // MySQL needs a space after --, otherwise 1--1 is arithmetic + int eol = sql.indexOf('\n', i); + i = eol < 0 ? n : eol; + sb.append(' '); + } else if (c == '/' && next == '*') { + int end = sql.indexOf("*/", i + 2); + if (end < 0 || sql.charAt(i + 2) == '!' || sql.substring(i + 2, end).contains("/*")) { + return null; + } + i = end + 2; + sb.append(' '); + } else if (c == '\'' || c == '"' || c == '`') { + int end = closingQuote(sql, i + 1, c, mysql && c != '`'); + if (end < 0) { + return null; + } + i = end + 1; + sb.append(' '); + } else if (c == '$' && !mysql && (i == 0 || !isIdentifierPart(sql.charAt(i - 1)))) { + // PostgreSQL dollar quoting: $$...$$ or $tag$...$tag$ + int tagEnd = sql.indexOf('$', i + 1); + String tag = tagEnd > 0 ? sql.substring(i, tagEnd + 1) : null; + if (tag != null + && tag.substring(1, tag.length() - 1).chars().allMatch(SqlReadOnlyGuard::isIdentifierPart)) { + int end = sql.indexOf(tag, tagEnd + 1); + if (end < 0) { + return null; + } + i = end + tag.length(); + sb.append(' '); + } else { + sb.append(c); + i++; + } + } else { + sb.append(c); + i++; + } + } + return sb.toString(); + } + + private static boolean isIdentifierPart(int ch) { + return Character.isLetterOrDigit(ch) || ch == '_' || ch == '$'; + } + + /** + * The index of the quote that closes a literal opened before {@code from}, or -1; a doubled quote is an escape, and + * so is a backslash when asked for. + */ + private static int closingQuote(String sql, int from, char quote, boolean backslashEscapes) { + int i = from; + while (i < sql.length()) { + char c = sql.charAt(i); + if (backslashEscapes && c == '\\' && i + 1 < sql.length()) { + i += 2; + } else if (c == quote) { + if (i + 1 < sql.length() && sql.charAt(i + 1) == quote) { + i += 2; + } else { + return i; + } + } else { + i++; + } + } + return -1; + } + + private static List<String> words(String code) { + List<String> words = new ArrayList<>(); + for (String w : code.split("[^A-Za-z0-9_]+")) { + if (!w.isEmpty()) { + words.add(w.toUpperCase(Locale.ROOT)); + } + } + return words; + } +} 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..e999a0ad8652 --- /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,190 @@ +/* + * 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_QUERY_TOOL = "camel_runtime_sql_query"; + 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 sqlReadOnly whether SQL is limited to reading (only meaningful when the SQL group is loaded) + * @param fingerprint stable for the same groups, datasources and SQL mode: a client rebuilds its tool list only + * when it changes + */ + public record Selection(List<Group> groups, boolean sqlReadOnly, 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; with {@code sqlWrites} the SQL group also offers the tool that writes. */ + public static Selection select(AppFeatures features, boolean sqlWrites) { + 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, + sqlWrites + ? List.of(SQL_QUERY_TOOL, SQL_TOOL, DATASOURCES_TOOL, SQL_TRACE_TOOL) + : List.of(SQL_QUERY_TOOL, DATASOURCES_TOOL, SQL_TRACE_TOOL), + sqlGuidance(f, sqlWrites))); + 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, !sqlWrites, fingerprint(groups(f), f, sqlWrites)); + } + + private static String sqlGuidance(AppFeatures f, boolean sqlWrites) { + String mode = sqlWrites + ? SQL_QUERY_TOOL + " reads, " + SQL_TOOL + " also writes." + : "Read-only: SELECT only (" + SQL_QUERY_TOOL + ")."; + return "SQL: " + describeSql(f) + ". " + mode + " Table names come from the SQL trace (" + SQL_TRACE_TOOL + + "); don't guess a schema."; + } + + /** 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, and the SQL mode when the SQL group is loaded: the same integration gives + * the same fingerprint, whatever order its status lists things in. + */ + static String fingerprint(List<ToolGroup> groups, AppFeatures features, boolean sqlWrites) { + String ids = groups.stream().map(ToolGroup::id).sorted().collect(Collectors.joining(",")); + String ds = features.dataSourceNames().stream().sorted().collect(Collectors.joining(",")); + String mode = groups.contains(ToolGroup.SQL) ? (sqlWrites ? "rw" : "ro") : ""; + return ids + "|" + ds + "|" + mode; + } + + /** + * 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..efc1829fbfab 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 @@ -422,23 +422,47 @@ public final class ToolRegistry { "Name of the DataSource bean (auto-detected if only one exists)", false) .param("maxRows", "string", "Maximum number of rows to return (default: 100)", false) .readOnly(false).destructive(true) + .executor(ToolRegistry::executeSql)); + + // CAMEL-24834: the SQL a small local model gets, which cannot change the database + register(tool("query_sql", + "Run a read-only SQL query (SELECT, WITH, SHOW, EXPLAIN, DESCRIBE) against a DataSource in the running " + + "Camel application. Returns structured JSON with columns, rows, and metadata. " + + "Any statement that writes is refused.") + .param("query", "string", "The SQL query to run", true) + .param("datasource", "string", + "Name of the DataSource bean (auto-detected if only one exists)", false) + .param("maxRows", "string", "Maximum number of rows to return (default: 100)", false) + .readOnly(true).destructive(false) .executor((ctx, args) -> { String sql = args.get("query"); - if (sql == null || sql.isBlank()) { - throw new ToolExecutionException("'query' parameter is required"); - } - String datasource = args.get("datasource"); - int maxRows = 100; - String maxRowsStr = args.get("maxRows"); - if (maxRowsStr != null && !maxRowsStr.isBlank()) { - try { - maxRows = Integer.parseInt(maxRowsStr); - } catch (NumberFormatException e) { - // use default + if (sql != null && !sql.isBlank()) { + String refused = SqlReadOnlyGuard.check(sql); + if (refused != null) { + throw new ToolExecutionException(refused); } } - JsonObject result = ctx.executeSqlQuery(sql, datasource, maxRows, 30); - return result.toJson(); + return executeSql(ctx, args); + })); + + 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) + .param("sqlWrites", "boolean", "Offer the SQL tool that writes too (default false: read-only SQL)", + false) + .executor((ctx, args) -> { + String name = args.get("name"); + if (name != null && !name.isBlank()) { + ctx.selectProcess(name); + } else { + ctx.selectSingleProcessIfNone(); + } + JsonObject status = ctx.readFullStatus(); + boolean sqlWrites = "true".equalsIgnoreCase(args.get("sqlWrites")); + return toolGroups(ctx, status, sqlWrites).toJson(); })); // Route control @@ -455,6 +479,63 @@ public final class ToolRegistry { .executor((ctx, args) -> ctx.stopApplication())); } + private static String executeSql(ToolContext ctx, Map<String, String> args) { + String sql = args.get("query"); + if (sql == null || sql.isBlank()) { + throw new ToolExecutionException("'query' parameter is required"); + } + String datasource = args.get("datasource"); + int maxRows = 100; + String maxRowsStr = args.get("maxRows"); + if (maxRowsStr != null && !maxRowsStr.isBlank()) { + try { + maxRows = Integer.parseInt(maxRowsStr); + } catch (NumberFormatException e) { + // use default + } + } + JsonObject result = ctx.executeSqlQuery(sql, datasource, maxRows, 30); + return result.toJson(); + } + + /** + * 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, boolean sqlWrites) { + AppFeatures features = AppFeatures.fromStatus(status); + ToolGroups.Selection selection = ToolGroups.select(features, sqlWrites); + 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()); + answer.put("sqlReadOnly", selection.sqlReadOnly()); + 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/SqlReadOnlyGuardTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SqlReadOnlyGuardTest.java new file mode 100644 index 000000000000..acc8f9571b60 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SqlReadOnlyGuardTest.java @@ -0,0 +1,101 @@ +/* + * 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 org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SqlReadOnlyGuardTest { + + @ParameterizedTest + @ValueSource(strings = { + "SELECT * FROM orders", + "select id, name from orders where id = 1;", + " SELECT 1 ; ", + "(SELECT 1) UNION (SELECT 2)", + "WITH recent AS (SELECT * FROM orders WHERE ts > now()) SELECT count(*) FROM recent", + "VALUES (1, 'a')", + "TABLE orders", + "SHOW TABLES", + "SHOW CREATE TABLE orders", + "EXPLAIN SELECT * FROM orders", + "DESCRIBE orders", + "DESC orders", + "SELECT * FROM orders ORDER BY id DESC", + "-- the orders\nSELECT * FROM orders", + "/* all of them */ SELECT * FROM orders", + "SELECT 'DELETE FROM orders; DROP TABLE x' AS text FROM orders", + "SELECT 'a;b' FROM orders WHERE note = 'it''s; fine'", + "SELECT \"update\" FROM \"insert\"", + "SELECT replace(name, 'a', 'b') FROM orders", + "SELECT * FROM orders WHERE id = $1", + "SELECT 1 # a MySQL comment\n" + }) + void allowsReads(String sql) { + assertNull(SqlReadOnlyGuard.check(sql), sql); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + "INSERT INTO orders VALUES (1)", + "update orders set name = 'x'", + "DELETE FROM orders", + "DROP TABLE orders", + "MERGE INTO orders USING x ON (1=1) WHEN MATCHED THEN DELETE", + "SELECT 1; DELETE FROM orders", + "SELECT 1; SELECT 2", + "WITH gone AS (DELETE FROM orders RETURNING *) SELECT * FROM gone", + "WITH x AS (INSERT INTO orders VALUES (1) RETURNING id) SELECT id FROM x", + "SELECT * INTO backup FROM orders", + "SELECT * FROM orders FOR UPDATE", + "SELECT * FROM orders FOR SHARE", + "SELECT * FROM orders FOR NO KEY UPDATE", + "SELECT * FROM orders LOCK IN SHARE MODE", + "EXPLAIN ANALYZE DELETE FROM orders", + "EXPLAIN (ANALYZE) SELECT * FROM orders", + "CALL cleanup()", + "SELECT 1 /* unterminated", + "SELECT 'unterminated", + // a comment hides the second statement in one reading only + "SELECT 1 -- \n; DELETE FROM orders", + "SELECT 1 /* /* */ ' */ ; DELETE FROM orders; -- '", + "SELECT 1 /*! ; DELETE FROM orders */", + "SELECT 'a\\' ; DELETE FROM orders; -- '", + "SELECT 1 # '\n; DELETE FROM orders; -- '", + "SELECT 1 --x' \n '; DELETE FROM orders; -- '", + // PostgreSQL dollar quoting is no quoting in MySQL + "SELECT $$; DELETE FROM orders$$" + }) + void refusesWrites(String sql) { + String answer = SqlReadOnlyGuard.check(sql); + assertNotNull(answer, sql); + assertTrue(answer.startsWith("read-only: "), answer); + assertTrue(answer.contains("ask the user to enable SQL writes"), answer); + } + + @ParameterizedTest + @ValueSource(strings = { "SELECT 1; SELECT 2" }) + void saysWhy(String sql) { + assertTrue(SqlReadOnlyGuard.check(sql).contains("one statement at a time")); + assertTrue(SqlReadOnlyGuard.check("DELETE FROM orders").contains("DELETE is not a read")); + } +} 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..2d18477e3ab5 --- /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,102 @@ +/* + * 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(), false); + assertTrue(s.groups().isEmpty()); + assertTrue(s.mcpTools().isEmpty()); + assertTrue(s.guidance().isEmpty()); + assertEquals("||", s.fingerprint()); + } + + @Test + void eachGroupHasItsTools() { + ToolGroups.Selection s = ToolGroups.select(everything(), false); + assertEquals(List.of(ToolGroup.SQL, ToolGroup.TRACING, ToolGroup.RESILIENCE), s.toolGroups()); + assertEquals(List.of("camel_runtime_sql_query", "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()); + assertTrue(s.sqlReadOnly()); + assertFalse(s.mcpTools().contains("camel_runtime_sql"), "read-only SQL has no tool that writes"); + } + + @Test + void sqlWritesAddTheToolThatWrites() { + ToolGroups.Selection s = ToolGroups.select(everything(), true); + assertFalse(s.sqlReadOnly()); + assertTrue(s.mcpTools().containsAll(List.of("camel_runtime_sql_query", "camel_runtime_sql"))); + assertTrue(s.guidance().get(0).contains("camel_runtime_sql also writes"), s.guidance().get(0)); + } + + @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, false); + 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(), false).guidance(); + assertEquals(3, guidance.size()); + assertTrue(guidance.get(0).startsWith("SQL: datasource(s) orders (HikariCP), audit, used by sql endpoints."), + guidance.get(0)); + assertTrue(guidance.get(0).contains("Read-only: SELECT only"), guidance.get(0)); + assertTrue(guidance.get(0).contains("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(), false).fingerprint(); + assertEquals("resilience,sql,tracing|audit,orders|ro", fp); + assertEquals(fp, ToolGroups.select(reordered, false).fingerprint()); + assertNotEquals(fp, ToolGroups.select(everything(), true).fingerprint(), "the SQL mode changes the tools"); + } +} 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..52d4f6a81cec 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,57 @@ class ToolRegistryTest { () -> ToolRegistry.execute("execute_sql", ctx, Map.of())); } + @Test + void querySqlRefusesWritesBeforeLookingForAProcess() { + // CAMEL-24834: no process selected, so a statement that got past the guard would fail with "No running Camel + // process" instead + ToolDescriptor tool = ToolRegistry.findTool("query_sql"); + assertNotNull(tool); + assertTrue(tool.isReadOnly()); + assertFalse(tool.isDestructive()); + ToolContext ctx = new ToolContext(); + ToolExecutionException e = assertThrows(ToolExecutionException.class, + () -> ToolRegistry.execute("query_sql", ctx, Map.of("query", "INSERT INTO orders VALUES (1)"))); + assertTrue(e.getMessage().startsWith("read-only: "), e.getMessage()); + e = assertThrows(ToolExecutionException.class, + () -> ToolRegistry.execute("query_sql", ctx, Map.of("query", "SELECT 1"))); + assertFalse(e.getMessage().startsWith("read-only: "), "a read goes on to the process: " + e.getMessage()); + } + + @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)); + assertFalse(ToolRegistry.authoringTools().contains(ToolRegistry.findTool("query_sql"))); + } + + @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, false); + assertEquals("shop", answer.get("app")); + assertEquals(99999L, answer.get("pid")); + assertEquals(Boolean.TRUE, answer.get("sqlReadOnly")); + assertEquals("sql|orders|ro", 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_query")); + 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..595c9053f98e 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 @@ -26,6 +26,7 @@ import jakarta.inject.Inject; import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolCallException; +import org.apache.camel.dsl.jbang.core.commands.ai.SqlReadOnlyGuard; import org.apache.camel.dsl.jbang.core.commands.ai.ToolContext; import org.apache.camel.dsl.jbang.core.commands.ai.ToolExecutionException; import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry; @@ -286,6 +287,49 @@ public class RuntimeTools { return delegateToRegistry("execute_sql", nameOrPid, args); } + @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false), + description = """ + Run a read-only SQL query against a DataSource of the running Camel application: \ + SELECT, WITH, VALUES, SHOW, EXPLAIN or DESCRIBE, one statement. Returns columns, rows and metadata; \ + a statement that writes is refused. Take the table names from camel_runtime_sql_trace.""") + public JsonObject camel_runtime_sql_query( + @ToolArg(description = NAME_OR_PID_DESC, required = false) String nameOrPid, + @ToolArg(description = "The SQL query to run") String query, + @ToolArg(description = "Name of the DataSource bean (auto-detected if only one exists)", + required = false) String datasource, + @ToolArg(description = "Maximum number of rows to return (default 100)", required = false) String maxRows) { + if (query == null || query.isBlank()) { + throw new ToolCallException("query is required", null); + } + // refuse before looking for a process, so a write never depends on what runs + String refused = SqlReadOnlyGuard.check(query); + if (refused != null) { + throw new ToolCallException(refused, null); + } + Map<String, String> args = new HashMap<>(); + args.put("query", query); + putIfNotBlank(args, "datasource", datasource); + putIfNotBlank(args, "maxRows", maxRows); + return delegateToRegistry("query_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, + @ToolArg(description = "Also offer camel_runtime_sql, which writes (default false: read-only SQL)", + required = false) Boolean sqlWrites) { + Map<String, String> args = new HashMap<>(); + if (sqlWrites != null && sqlWrites) { + args.put("sqlWrites", "true"); + } + return delegateToRegistry("get_tool_groups", nameOrPid, args); + } + @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..7542f3705d38 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,11 @@ */ package org.apache.camel.dsl.jbang.core.commands.mcp; +import java.lang.reflect.Method; +import java.util.Arrays; 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 +78,46 @@ class RuntimeToolsTest { .hasMessageContaining("query is required"); } + @Test + void sqlQueryRequiresQuery() { + RuntimeTools tools = createTools(); + assertThatThrownBy(() -> tools.camel_runtime_sql_query(null, " ", null, null)) + .isInstanceOf(ToolCallException.class) + .hasMessageContaining("query is required"); + } + + @Test + void sqlQueryRefusesWritesBeforeLookingForAProcess() { + // CAMEL-24834: the refusal does not depend on what runs, so it is the same with no process at all + RuntimeTools tools = createTools(); + assertThatThrownBy(() -> tools.camel_runtime_sql_query("no-such-app", "DELETE FROM orders", null, null)) + .isInstanceOf(ToolCallException.class) + .hasMessageStartingWith("read-only: "); + assertThatThrownBy(() -> tools.camel_runtime_sql_query(null, "SELECT 1; DROP TABLE orders", null, null)) + .isInstanceOf(ToolCallException.class) + .hasMessageContaining("one statement at a time"); + } + + @Test + void theReadOnlyToolsAreVisibleAtTheReadOnlyAccessLevel() throws Exception { + // McpAccessFilter decides from the annotations: read-only hints keep the tools for a read-only client + for (String name : List.of("camel_runtime_sql_query", "camel_runtime_tool_groups")) { + Method m = Arrays.stream(RuntimeTools.class.getDeclaredMethods()) + .filter(dm -> dm.getName().equals(name)).findFirst().orElseThrow(); + Tool tool = m.getAnnotation(Tool.class); + assertThat(McpSecurityConfig.AccessLevel.READ_ONLY.permits( + tool.annotations().readOnlyHint(), tool.annotations().destructiveHint())).as(name).isTrue(); + } + Method sql = Arrays.stream(RuntimeTools.class.getDeclaredMethods()) + .filter(dm -> dm.getName().equals("camel_runtime_sql")).findFirst().orElseThrow(); + assertThat(sql.getAnnotation(Tool.class).annotations().readOnlyHint()).isFalse(); + } + @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", "query_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..e8310639d314 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,8 @@ 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.commands.ai.SqlReadOnlyGuard; 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 +304,33 @@ 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(); camel.tui.ai.sqlWrites decides whether SQL may write + private AppStatusSource appStatusSource; + private String toolGroupsPid; + private int toolGroupsReloads = -1; + private AppFeatures toolGroupsFeatures = AppFeatures.none(); + private volatile TuiToolGroups.Selection toolGroups = TuiToolGroups.Selection.none(); + private volatile boolean sqlWrites; + private Boolean sqlWritesForTesting; private boolean mcpServerActive; private int mcpServerPort; @@ -1561,6 +1585,7 @@ class AiPanel { if (!testingClientInjected) { toolMode = normalizeToolMode(TuiSettings.load().getAiTools()); } + refreshToolGroups(); tools = buildTuiToolDefinitions(); String systemPrompt = buildSystemPrompt(); @@ -3328,9 +3353,69 @@ 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), or the SQL mode + * changed, 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() { + boolean writes = sqlWritesForTesting != null ? sqlWritesForTesting : TuiSettings.load().isAiSqlWrites(); + if (!useCoreTools()) { + sqlWrites = writes; + 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); + boolean reread = !samePid || reloads != toolGroupsReloads || toolGroups.groups().isEmpty(); + if (!reread && writes == toolGroups.sqlWrites()) { + return; + } + if (reread) { + AppFeatures read = pid != null ? source.features() : AppFeatures.none(); + toolGroupsFeatures = samePid ? toolGroupsFeatures.merge(read) : read; + toolGroupsPid = pid; + toolGroupsReloads = reloads; + } + toolGroups = TuiToolGroups.select(toolGroupsFeatures, writes); + sqlWrites = writes; + } + + 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 +3453,21 @@ 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)"; + if (!sqlWrites) { + groups += "; SQL read-only"; + } + } + return (useCoreTools() ? "core" : "full") + " (" + active + " of " + total + " tools), mode " + mode + detail + + groups; } private List<LlmClient.ToolDef> buildTuiToolDefinitions() { @@ -3380,8 +3475,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())); } @@ -3798,6 +3893,10 @@ class AiPanel { if (toolRegistry == null) { return "Error: TUI tools not available"; } + String refused = refuseSqlWrite(name, args); + if (refused != null) { + return "Error: " + refused; + } try { return toolRegistry.execute(name, args); } catch (IllegalArgumentException e) { @@ -3807,6 +3906,26 @@ class AiPanel { } } + /** + * Keeps a local model (the core set) from writing to the database unless camel.tui.ai.sqlWrites is true: SQL that + * writes is refused, and so is tui_update_row, which the core set then does not even offer. + */ + private String refuseSqlWrite(String name, JsonObject args) { + if (sqlWrites || !useCoreTools()) { + return null; + } + if (TuiToolGroups.SQL_TOOL.equals(name)) { + String query = args != null && args.get("query") instanceof String q ? q : null; + String refused = query != null ? SqlReadOnlyGuard.check(query) : null; + return refused != null ? refused + " (" + TuiSettings.PROP_AI_SQL_WRITES + "=true)" : null; + } + if (TuiToolGroups.UPDATE_ROW_TOOL.equals(name)) { + return "read-only: " + name + " changes the database; ask the user to enable SQL writes (" + + TuiSettings.PROP_AI_SQL_WRITES + "=true)"; + } + return null; + } + // F8 intentionally excluded — it closes the panel and is handled above private static boolean isFunctionKey(KeyEvent ke) { KeyCode code = ke.code(); @@ -3975,6 +4094,26 @@ class AiPanel { return buildSystemPrompt(); } + void setAppStatusSourceForTesting(AppStatusSource source) { + this.appStatusSource = source; + } + + void setSqlWritesForTesting(Boolean writes) { + this.sqlWritesForTesting = writes; + } + + void refreshToolGroupsForTesting() { + refreshToolGroups(); + } + + TuiToolGroups.Selection toolGroupsForTesting() { + return toolGroups; + } + + String executeTuiToolForTesting(String name, JsonObject args) { + return executeTuiTool(name, args); + } + 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..6575146591c6 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,27 @@ 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 (NumberFormatException e) { + 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/TuiSettings.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettings.java index 37daa098baa8..48226d1d24cb 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettings.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettings.java @@ -42,6 +42,7 @@ final class TuiSettings { static final String PROP_AI_TOOLS = "camel.tui.ai.tools"; static final String PROP_AI_OVERVIEW = "camel.tui.ai.overview"; static final String PROP_AI_ACP_COMMAND = "camel.tui.ai.acp.command"; + static final String PROP_AI_SQL_WRITES = "camel.tui.ai.sqlWrites"; static final String PROP_PROXY_HOST = "camel.tui.proxyHost"; static final String PROP_PROXY_PORT = "camel.tui.proxyPort"; static final String PROP_SHELL_HISTORY = "camel.tui.shell.history"; @@ -65,6 +66,7 @@ final class TuiSettings { private String aiTools; private String aiOverview; private String aiAcpCommand; + private String aiSqlWrites; private String shellHistory; private String aiPromptHistory; private String confirmActions; @@ -193,6 +195,23 @@ final class TuiSettings { this.aiAcpCommand = aiAcpCommand; } + /** + * Whether the AI panel lets a local model (the core tool set) write to the integration's database (CAMEL-24834): + * {@code false} (default) limits tui_execute_sql to reading and leaves tui_update_row out, {@code true} allows + * both. The full tool set is not limited. + */ + String getAiSqlWrites() { + return aiSqlWrites; + } + + void setAiSqlWrites(String aiSqlWrites) { + this.aiSqlWrites = aiSqlWrites; + } + + boolean isAiSqlWrites() { + return "true".equalsIgnoreCase(aiSqlWrites); + } + String getShellHistory() { return shellHistory; } @@ -289,6 +308,7 @@ final class TuiSettings { settings.aiTools = trimToNull(TuiUserConfig.read(PROP_AI_TOOLS)); settings.aiOverview = trimToNull(TuiUserConfig.read(PROP_AI_OVERVIEW)); settings.aiAcpCommand = trimToNull(TuiUserConfig.read(PROP_AI_ACP_COMMAND)); + settings.aiSqlWrites = trimToNull(TuiUserConfig.read(PROP_AI_SQL_WRITES)); settings.shellHistory = trimToNull(TuiUserConfig.read(PROP_SHELL_HISTORY)); settings.aiPromptHistory = trimToNull(TuiUserConfig.read(PROP_AI_PROMPT_HISTORY)); settings.confirmActions = trimToNull(TuiUserConfig.read(PROP_CONFIRM_ACTIONS)); @@ -322,6 +342,7 @@ final class TuiSettings { TuiUserConfig.write(PROP_AI_TOOLS, aiTools); TuiUserConfig.write(PROP_AI_OVERVIEW, aiOverview); TuiUserConfig.write(PROP_AI_ACP_COMMAND, aiAcpCommand); + TuiUserConfig.write(PROP_AI_SQL_WRITES, aiSqlWrites); TuiUserConfig.write(PROP_SHELL_HISTORY, shellHistory); TuiUserConfig.write(PROP_AI_PROMPT_HISTORY, aiPromptHistory); TuiUserConfig.write(PROP_CONFIRM_ACTIONS, confirmActions); 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..5a22971bab8d --- /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,110 @@ +/* + * 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 + * @param sqlWrites whether SQL may write + */ + record Selection(List<ToolGroup> groups, List<String> tools, List<String> guidance, boolean sqlWrites) { + + 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(), false); + } + + String groupIds() { + return groups.stream().map(ToolGroup::id).collect(Collectors.joining(", ")); + } + } + + private TuiToolGroups() { + } + + static Selection select(AppFeatures features, boolean sqlWrites) { + 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, sqlWrites)); + guidance.add(guidance(group, f, sqlWrites)); + } + return new Selection(groups, tools, guidance, sqlWrites); + } + + static List<String> tools(ToolGroup group, boolean sqlWrites) { + if (group == ToolGroup.SQL) { + return sqlWrites ? List.of(SQL_TOOL, UPDATE_ROW_TOOL) : List.of(SQL_TOOL); + } + return List.of(); + } + + static String guidance(ToolGroup group, AppFeatures f, boolean sqlWrites) { + return switch (group) { + case SQL -> "SQL: " + ToolGroups.describeSql(f) + ". " + + (sqlWrites + ? SQL_TOOL + " runs any statement, " + UPDATE_ROW_TOOL + " changes one row." + : "Read-only: " + SQL_TOOL + " runs SELECT only.") + + " 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..6e9fb6e7619c 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,10 @@ 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.0k tokens for 25 tools: the core set (~4.7k) plus every tool group (CAMEL-24834). */ + // the SQL group adds tui_execute_sql (~190 tokens), each group one guidance line in the prompt (~140 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_300; record Prefix(String mode, int tools, long promptChars, long toolChars) { @@ -99,10 +107,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(boolean sqlWrites) { + AiPanelToolGroupsTest.FakeApp app = new AiPanelToolGroupsTest.FakeApp(); + app.features = AiPanelToolGroupsTest.EVERYTHING; + AiPanel panel = AiPanelToolGroupsTest.panel(AiPanel.TOOL_MODE_CORE, app, sqlWrites); + panel.refreshToolGroupsForTesting(); + return panel; + } + @Test void corePrefixStaysWithinBudget() { Prefix core = measure(AiPanel.TOOL_MODE_CORE); @@ -112,6 +133,17 @@ class AiPanelPromptBudgetTest { "core prefix grew to ~" + core.totalTokens() + " tokens, budget " + CORE_BUDGET_TOKENS + ": " + core); } + @Test + void coreWithAllGroupsPrefixStaysWithinBudget() { + Prefix groups = measure("core+groups", coreWithAllGroups(false)); + 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 +186,23 @@ 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, with SQL writes or not + for (boolean sqlWrites : List.of(false, true)) { + AiPanel groups = coreWithAllGroups(sqlWrites); + 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"); + assertTrue(prompt.contains("tui_update_row") == sqlWrites, "tui_update_row only with SQL writes"); + } } } 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..9a8e635adb7a --- /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,218 @@ +/* + * 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.apache.camel.util.json.JsonObject; +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, boolean sqlWrites) { + AiPanel panel = new AiPanel(); + panel.setToolRegistryForTesting(new TuiToolRegistry(null)); + panel.setToolModeForTesting(mode); + panel.setAppStatusSourceForTesting(app); + panel.setSqlWritesForTesting(sqlWrites); + return panel; + } + + static List<String> toolNames(AiPanel panel) { + return panel.toolDefinitionsForTesting().stream().map(LlmClient.ToolDef::name).toList(); + } + + @Test + void theSqlGroupAddsTheQueryToolOnly() { + AiPanel panel = panel(AiPanel.TOOL_MODE_CORE, new FakeApp(), false); + 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).contains("tui_execute_sql")); + assertFalse(toolNames(panel).contains("tui_update_row"), "writes are off"); + assertTrue(panel.systemPromptForTesting().contains("Read-only: tui_execute_sql runs SELECT only")); + assertTrue(panel.describeToolModeForTesting().contains("groups: sql (from the selected integration)"), + panel.describeToolModeForTesting()); + assertTrue(panel.describeToolModeForTesting().contains("SQL read-only")); + + AiPanel writes = panel(AiPanel.TOOL_MODE_CORE, new FakeApp(), true); + writes.refreshToolGroupsForTesting(); + assertTrue(toolNames(writes).containsAll(List.of("tui_execute_sql", "tui_update_row"))); + assertFalse(writes.describeToolModeForTesting().contains("SQL read-only")); + } + + @Test + void anotherIntegrationGetsItsOwnGroups() { + FakeApp app = new FakeApp(); + AiPanel panel = panel(AiPanel.TOOL_MODE_CORE, app, false); + 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, false); + 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, false); + 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()); + + // turning SQL writes on changes the tools without reading the status + panel.setSqlWritesForTesting(true); + panel.refreshToolGroupsForTesting(); + assertEquals(1, app.reads); + assertTrue(toolNames(panel).contains("tui_update_row")); + } + + @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, false); + 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(), false); + AiPanel panel = panel(AiPanel.TOOL_MODE_FULL, app, false); + 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")); + // and writing is not limited there + String answer = panel.executeTuiToolForTesting("tui_execute_sql", query("DELETE FROM orders")); + assertFalse(answer.contains("read-only"), answer); + } + + @Test + void theCoreSetRefusesSqlThatWrites() { + AiPanel panel = panel(AiPanel.TOOL_MODE_CORE, new FakeApp(), false); + panel.refreshToolGroupsForTesting(); + + String answer = panel.executeTuiToolForTesting("tui_execute_sql", query("INSERT INTO orders VALUES (1)")); + assertTrue(answer.startsWith("Error: read-only: "), answer); + assertTrue(answer.contains("camel.tui.ai.sqlWrites=true"), answer); + answer = panel.executeTuiToolForTesting("tui_update_row", new JsonObject()); + assertTrue(answer.startsWith("Error: read-only: tui_update_row"), answer); + // a read goes on to the tool (which has no integration here) + answer = panel.executeTuiToolForTesting("tui_execute_sql", query("SELECT * FROM orders")); + assertFalse(answer.contains("read-only"), answer); + + AiPanel writes = panel(AiPanel.TOOL_MODE_CORE, new FakeApp(), true); + writes.refreshToolGroupsForTesting(); + answer = writes.executeTuiToolForTesting("tui_execute_sql", query("INSERT INTO orders VALUES (1)")); + assertFalse(answer.contains("read-only"), answer); + } + + private static JsonObject query(String sql) { + JsonObject args = new JsonObject(); + args.put("query", sql); + return args; + } +}
