This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit f33abe089d7c230d17a3766894d290bf30d07fcb Author: Claus Ibsen <[email protected]> AuthorDate: Tue Sep 8 16:19:08 2026 +0200 CAMEL-24656: camel-jbang TUI - send a smaller, cache-friendly prompt from the AI panel to local models The F8 panel sent ~8.1k tokens of static prefix on every request: 46 tool schemas plus a system prompt that repeated the whole tool list in prose and embedded the selected integration and PID. Local models process prompts slowly, and the PID line invalidated the prompt cache whenever the selection changed. - Add TuiToolRegistry.CORE_TOOLS (18 tools for Q&A and troubleshooting) and send only those to local providers (Ollama, or any provider on localhost) by default. /tools [auto|core|full] shows or switches the set and persists it as camel.tui.ai.tools. - Drop the prose tool list from the system prompt (the tool definitions already carry the descriptions) and keep it free of per-turn state; the selected integration now travels in the user message. - Ask Ollama to keep the model loaded for 30 minutes and use a 32k context (OLLAMA_CONTEXT_LENGTH overrides) so follow-up turns hit the prompt cache instead of reloading the model or truncating the prompt. Measured static prefix per request: ~8.1k tokens before, ~3.1k tokens for local providers and ~7.3k for hosted providers after. Co-Authored-By: Claude Fable 5.1 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 8 + .../modules/ROOT/pages/camel-jbang-tui.adoc | 16 ++ .../camel/dsl/jbang/core/commands/LlmClient.java | 67 +++++++- .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 173 ++++++++++++++------- .../core/commands/tui/AiSlashCommandContext.java | 11 ++ .../core/commands/tui/AiSlashCommandRegistry.java | 14 ++ .../dsl/jbang/core/commands/tui/TuiSettings.java | 16 ++ .../jbang/core/commands/tui/TuiToolRegistry.java | 22 ++- .../dsl/jbang/core/commands/tui/AiPanelTest.java | 71 +++++++++ .../commands/tui/AiSlashCommandRegistryTest.java | 69 +++++++- .../jbang/core/commands/tui/TuiSettingsTest.java | 2 + .../commands/tui/TuiToolRegistryCoreToolsTest.java | 61 ++++++++ 12 files changed, 466 insertions(+), 64 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index fb25b895e6e9..d845030dfef8 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -612,6 +612,14 @@ shape. The `camel get variable` CLI command has already been updated accordingly === camel-jbang (TUI) +The F8 AI panel now sends only a core subset of its `tui_*` tools to local providers (Ollama, or any +provider on `localhost`); the drawing, animation and automation tools are left out to keep the +prompt small for local models. Hosted providers are unaffected. Use `/tools full` in the panel, or +set `camel.tui.ai.tools=full`, to restore the previous behaviour. Requests to Ollama now also set +`keep_alive` to 30 minutes and `num_ctx` to 32768 (or `OLLAMA_CONTEXT_LENGTH` when that is set in the +environment), which can cause a one-time model reload if the model was loaded with a different +context size. + `camel tui --record` is now rejected when combined with `--web`. The recording configuration applies to the whole process, so a browser session served by `--web` would be recorded into the same `.cast` file as the local session. Previously the combination was accepted, but recording never produced any diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc index 90c4d0b0f1ed..96cef22d9c49 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -814,6 +814,19 @@ NOTE: On Apple Silicon, use the default (GGUF) tags rather than the `-mlx` tags. engine cannot yet reuse the cached prompt for Qwen 3.x models, so every question re-processes the whole prompt, while the default engine reuses it and only processes what is new. +==== Tool set for local models + +Every question sends the definitions of the `tui_*` tools the model may call, and a local model +pays for each of them in prompt-processing time. The panel therefore sends only the core set of +tools (state, tables, logs, errors, diagrams, topology, processor details, catalog docs, traces, +spans, route control, sending messages, source files, navigation, log level and filters) to Ollama +and to any provider on `localhost`, which roughly halves the prompt. Hosted providers get every +tool, including the drawing, animation and automation tools. Use `/tools full` in the panel to send +all tools to a local model too, `/tools core` to trim the set for a hosted one, or set +`camel.tui.ai.tools` in `.camel-cli.properties`. Ollama requests also ask the server to keep the +model loaded for 30 minutes and use a 32k context window (`OLLAMA_CONTEXT_LENGTH` overrides it), +so follow-up questions reuse the cached prompt instead of reloading the model. + ==== Using an OpenAI-compatible local server Set `LLM_API_KEY` and `LLM_BASE_URL` to connect to any OpenAI-compatible server @@ -875,6 +888,9 @@ When the AI panel is open, input that starts with `/` runs a local panel command | `/model [model-name]` (`/m`) | Show the current model, or switch the session model. +| `/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`. + | `/clear` (`/c`) | Clear the AI conversation, usage counters, and model context without changing the provider or model. diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java index 1d8470f287e7..d472c0511bb4 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java @@ -53,6 +53,18 @@ public class LlmClient { private static final String DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6"; private static final String DEFAULT_OPENAI_MODEL = "gpt-4o-mini"; private static final String DEFAULT_OLLAMA_MODEL = "llama3.2"; + /** + * Keep the model (and its prompt cache) loaded between turns of a conversation. Ollama's default of five minutes is + * shorter than a slow local answer plus the time the user spends reading it, after which the next request pays for + * a full model reload and re-processes the whole prompt. + */ + private static final String OLLAMA_KEEP_ALIVE = "30m"; + /** + * Context window requested from Ollama. The tool-calling system prompt alone is several thousand tokens, and older + * Ollama releases default to 4096 which silently truncates it; 32k leaves room for a long conversation with tool + * results while keeping the KV cache modest. {@code OLLAMA_CONTEXT_LENGTH} in the environment overrides it. + */ + private static final int OLLAMA_NUM_CTX = 32768; private static final String DEFAULT_WATSONX_URL = "https://us-south.ml.cloud.ibm.com"; private static final String DEFAULT_WATSONX_MODEL = "ibm/granite-4-1-8b-instruct"; private static final String DEFAULT_AZURE_API_VERSION = "2024-10-21"; @@ -194,6 +206,27 @@ public class LlmClient { return url; } + /** + * Whether the model runs on this machine: the Ollama provider, or any provider whose endpoint host is a loopback + * address (LM Studio, llama.cpp server, vLLM and similar OpenAI-compatible servers). Local models process prompts + * far slower than hosted ones, so callers use this to trim what they send per request. + */ + public boolean isLocalEndpoint() { + if (apiType == ApiType.ollama) { + return true; + } + if (url == null) { + return false; + } + try { + String host = URI.create(url).getHost(); + return host != null && (host.equalsIgnoreCase("localhost") || host.equals("127.0.0.1") + || host.equals("::1") || host.equals("[::1]") || host.equals("0.0.0.0")); + } catch (IllegalArgumentException e) { + return false; + } + } + // -- Builder -- public static LlmClient create() { @@ -559,10 +592,8 @@ public class LlmClient { request.put("prompt", userPrompt); request.put("system", systemPrompt); request.put("stream", stream); - - JsonObject options = new JsonObject(); - options.put("temperature", temperature); - request.put("options", options); + request.put("keep_alive", OLLAMA_KEEP_ALIVE); + request.put("options", ollamaOptions()); if (stream) { return sendStreamingRequest(url + "/api/generate", request, null, "response"); @@ -936,6 +967,28 @@ public class LlmClient { return parseOpenAiChatResponse(response); } + private JsonObject ollamaOptions() { + JsonObject options = new JsonObject(); + options.put("temperature", temperature); + options.put("num_ctx", ollamaNumCtx()); + return options; + } + + static int ollamaNumCtx() { + String env = System.getenv("OLLAMA_CONTEXT_LENGTH"); + if (env != null && !env.isBlank()) { + try { + int value = Integer.parseInt(env.trim()); + if (value > 0) { + return value; + } + } catch (NumberFormatException e) { + // fall through to the default + } + } + return OLLAMA_NUM_CTX; + } + // ---- Ollama native chat with tools ---- private ChatResponse chatOllamaFormat(String systemPrompt, List<Message> messages, List<ToolDef> tools) { @@ -949,10 +1002,8 @@ public class LlmClient { if (jsonTools != null) { request.put("tools", jsonTools); } - - JsonObject options = new JsonObject(); - options.put("temperature", temperature); - request.put("options", options); + request.put("keep_alive", OLLAMA_KEEP_ALIVE); + request.put("options", ollamaOptions()); if (stream) { request.put("stream", true); 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 19caf4aecfd1..5e0107cafffc 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 @@ -76,6 +76,9 @@ class AiPanel { private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault()); private static final String INPUT_PROMPT = "❯ "; + static final String TOOL_MODE_AUTO = "auto"; + static final String TOOL_MODE_CORE = "core"; + static final String TOOL_MODE_FULL = "full"; private static final List<String> THINKING_VERBS = List.of( "Herding thoughts", "Chewing the cud", "Crossing the desert", "Loading the caravan", "Sniffing out an oasis", "Trekking onward", "Kicking up sand", "Grazing on context", @@ -133,6 +136,8 @@ class AiPanel { // Slash commands private final AiSlashCommandRegistry slashCommands = AiSlashCommandRegistry.defaults(); private AiSlashCommandContext slashCommandContext = new PanelSlashCommandContext(); + // auto | core | full, see useCoreTools(); loaded from camel.tui.ai.tools, null means auto + private volatile String toolMode; private final AiCliCommandExecutor cliCommandExecutor = new AiCliCommandExecutor(); private volatile CompletableFuture<AiCliCommandExecutor.Result> activeCliCommand; private Runnable exitCallback; @@ -307,6 +312,9 @@ class AiPanel { TuiSettings settings = TuiSettings.load(); providerSelector.applyChoice(created, settings.getAiProvider(), settings.getAiModel(), settings.getAiUrl()); } + if (toolMode == null) { + toolMode = normalizeToolMode(TuiSettings.load().getAiTools()); + } client = created; if (!client.detectEndpoint()) { initError @@ -887,7 +895,7 @@ class AiPanel { if (messages == null) { messages = new ArrayList<>(); } - messages.add(LlmClient.Message.user(question)); + messages.add(LlmClient.Message.user(contextualize(question))); LlmClient.TokenUsage totalUsage = LlmClient.TokenUsage.EMPTY; for (int i = 0; i < MAX_ITERATIONS; i++) { @@ -1661,67 +1669,31 @@ class AiPanel { """; } + /** + * The static prefix sent with every request. It deliberately contains nothing that changes between turns (the + * selected integration travels in the user message instead) so a local model's prompt cache can reuse it, and it + * does not repeat the tool list because the tool definitions already carry every description. + */ private String buildSystemPrompt() { StringBuilder sb = new StringBuilder(); sb.append("You are an Apache Camel assistant running inside the Camel TUI terminal console. "); sb.append("You help users understand and troubleshoot their running Camel integrations.\n\n"); - String selectedName = mcpFacade != null ? mcpFacade.getSelectedIntegrationName() : null; - String selectedPid = mcpFacade != null ? mcpFacade.getSelectedPid() : null; - if (selectedName != null && selectedPid != null) { - sb.append("The user is monitoring: ").append(selectedName); - sb.append(" (PID ").append(selectedPid).append(").\n\n"); - } - - sb.append("You have tui_* tools to observe and interact with the TUI:\n"); - sb.append("- tui_get_state: see which tab is active and which integration is selected\n"); - sb.append("- tui_get_table: get structured data from any tab WITHOUT navigating to it "); - sb.append("(Memory, Routes, Endpoints, Health, Process, Threads, Metrics, Startup, Heap Histogram, etc.)\n"); - sb.append("- tui_get_log: read application logs with filtering, WITHOUT navigating to the Log tab\n"); - sb.append("- tui_get_errors: get error details with stack traces, WITHOUT navigating to the Errors tab\n"); - sb.append("- tui_get_diagram: view route diagrams as text, WITHOUT navigating to the Diagram tab\n"); - sb.append("- tui_get_topology: see how routes connect to each other, WITHOUT navigating to the Topology tab\n"); - sb.append("- tui_get_processor_detail: get configured options for all processors in a route as structured JSON. "); - sb.append("USE THIS to explain what a route does, walk through each step, or understand EIP/component configuration. "); - sb.append("Set includeDocs=true to get catalog documentation for each option\n"); - sb.append("- tui_catalog_doc: look up Camel catalog documentation for any component, EIP, data format, or language\n"); - sb.append("- tui_get_ai_log: view the AI panel's own activity log (questions, tool calls, responses)\n"); - sb.append("- tui_get_mcp_log: view the MCP server's tool call log (external client connections and requests)\n"); - sb.append("- tui_get_history: trace exchange processing steps, WITHOUT navigating to the History tab\n"); - sb.append("- tui_get_spans: OpenTelemetry span data, WITHOUT navigating to the Spans tab\n"); - sb.append("- tui_navigate: switch tabs, select integrations, select routes "); - sb.append("- ONLY use when the user explicitly wants to change the view\n"); - sb.append("- tui_control: stop/start routes, restart or stop integrations\n"); - sb.append("- tui_send_message: send test messages to endpoints\n"); - sb.append("- tui_filter: set or clear text filters on any tab\n"); - sb.append("- tui_execute_sql: run SQL queries against a DataSource in the integration\n"); - sb.append("- tui_set_log_level: change the runtime log level\n"); - sb.append("- tui_draw_shape: draw shapes (box, highlight, arrow, underline, text) on screen to annotate problems\n"); - sb.append("- tui_draw_clear: clear drawing overlay\n"); - sb.append("- tui_locate: find elements on screen by text or diagram node ID, returns coordinates for drawing\n"); - sb.append("- tui_show_caption: display a message to the user on screen\n"); - sb.append("- tui_action: invoke TUI actions (reset-stats, screenshot, toggle-theme, etc.)\n"); - sb.append("- tui_get_themes / tui_set_theme: list and switch TUI themes\n"); - sb.append("- tui_get_files / tui_get_readme: read source files and README from integrations\n"); - sb.append("- tui_update_row: update a database row via PreparedStatement\n"); - sb.append("- tui_set_input: set input field values on tabs directly\n"); - sb.append("- tui_toggle_trace_display: control which sections show in History detail view\n"); - sb.append("- tui_canvas_open / tui_canvas_close: open/close a blank canvas for free-form drawing\n"); - sb.append("- tui_animate: run built-in animations on the canvas\n"); - sb.append("- tui_send_keys: send key presses to the TUI\n"); - sb.append("- tui_get_events: see recent user interaction events\n"); - sb.append("- tui_tape_start / tui_tape_stop: record TUI interactions as .tape files\n"); - sb.append("- tui_wait_for_idle / tui_sleep: timing tools for pacing interactions\n\n"); + sb.append("You have tui_* tools to observe and interact with the TUI; the tool definitions describe each one. "); + sb.append("All tui_get_* tools fetch data directly from any tab without changing what the user sees.\n\n"); sb.append("Guidelines:\n"); - sb.append("- NEVER call tui_navigate just to read data "); - sb.append("- all tui_get_* tools fetch data directly from any tab without changing the active tab\n"); + sb.append("- NEVER call tui_navigate just to read data - use the tui_get_* tools instead\n"); sb.append("- Prefer tui_get_table over tui_get_screen for structured data "); - sb.append("- it fetches from any tab directly using the tab parameter, no navigation needed\n"); - sb.append("- Use tui_get_state first to understand context before acting, if needed\n"); + sb.append("- it fetches from any tab using the tab parameter, no navigation needed\n"); + sb.append("- Call tui_get_options only when unsure which tab holds the data you need\n"); + sb.append("- Use tui_get_state to learn which integration and tab is selected, if the question depends on it\n"); + sb.append("- Use tui_get_processor_detail to explain what a route does or how its steps are configured\n"); sb.append("- Be concise and actionable in your answers\n"); sb.append("- When something looks wrong, explain what it means and suggest fixes\n"); sb.append("- For stopping routes or applications, use tui_control for graceful shutdown\n"); - sb.append("- Use tui_locate + tui_draw_shape to visually highlight problems on screen for the user\n"); + if (!useCoreTools()) { + sb.append("- Use tui_locate + tui_draw_shape to visually highlight problems on screen for the user\n"); + } if (mcpServerActive) { sb.append("\nThe TUI MCP server is available at http://localhost:") .append(mcpServerPort).append("/mcp for external AI agents."); @@ -1729,12 +1701,54 @@ class AiPanel { return sb.toString(); } + /** + * 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. + */ + private String contextualize(String question) { + String selectedName = mcpFacade != null ? mcpFacade.getSelectedIntegrationName() : null; + String selectedPid = mcpFacade != null ? mcpFacade.getSelectedPid() : null; + if (selectedName != null && selectedPid != null) { + return "[Monitoring " + selectedName + " (PID " + selectedPid + ")]\n" + question; + } + return question; + } + + /** + * Whether only the {@link TuiToolRegistry#CORE_TOOLS} are sent: always in {@code core} mode, never in {@code full} + * mode, and for local providers in {@code auto} mode. + */ + private boolean useCoreTools() { + String mode = toolMode == null ? TOOL_MODE_AUTO : toolMode; + if (TOOL_MODE_CORE.equals(mode)) { + return true; + } + if (TOOL_MODE_FULL.equals(mode)) { + return false; + } + return client != null && client.isLocalEndpoint(); + } + + private String describeToolMode() { + if (toolRegistry == null) { + return "no tools available"; + } + int total = toolRegistry.getToolDefinitions().size(); + int active = useCoreTools() ? toolRegistry.getCoreToolDefinitions().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; + } + private List<LlmClient.ToolDef> buildTuiToolDefinitions() { if (toolRegistry == null) { return List.of(); } List<LlmClient.ToolDef> defs = new ArrayList<>(); - for (TuiToolRegistry.ToolDef td : toolRegistry.getToolDefinitions()) { + List<TuiToolRegistry.ToolDef> source + = useCoreTools() ? toolRegistry.getCoreToolDefinitions() : toolRegistry.getToolDefinitions(); + for (TuiToolRegistry.ToolDef td : source) { defs.add(new LlmClient.ToolDef(td.name(), td.description(), td.inputSchema())); } return defs; @@ -1841,6 +1855,41 @@ class AiPanel { return inputBuffer.toString(); } + /** + * Maps a configured tool mode to {@code auto}, {@code core} or {@code full}; blank means {@code auto}, anything + * else is rejected with {@code null}. + */ + static String normalizeToolMode(String mode) { + if (mode == null || mode.isBlank()) { + return TOOL_MODE_AUTO; + } + String value = mode.trim().toLowerCase(); + return switch (value) { + case TOOL_MODE_AUTO, TOOL_MODE_CORE, TOOL_MODE_FULL -> value; + default -> null; + }; + } + + void setToolRegistryForTesting(TuiToolRegistry registry) { + this.toolRegistry = registry; + } + + void setToolModeForTesting(String mode) { + this.toolMode = normalizeToolMode(mode); + } + + String systemPromptForTesting() { + return buildSystemPrompt(); + } + + List<LlmClient.ToolDef> toolDefinitionsForTesting() { + return buildTuiToolDefinitions(); + } + + String describeToolModeForTesting() { + return describeToolMode(); + } + void setExitCallbackForTestingOrRuntime(Runnable callback) { this.exitCallback = callback; } @@ -1918,6 +1967,24 @@ class AiPanel { return ctx != null ? ctx.selectedName() : null; } + @Override + public String describeToolMode() { + return AiPanel.this.describeToolMode(); + } + + @Override + public boolean switchToolMode(String mode) { + String normalized = normalizeToolMode(mode); + if (normalized == null) { + return false; + } + toolMode = normalized; + TuiSettings settings = TuiSettings.load(); + settings.setAiTools(TOOL_MODE_AUTO.equals(normalized) ? null : normalized); + settings.save(); + return true; + } + @Override public CompletableFuture<AiCliCommandExecutor.Result> executeCli(AiCliCommandExecutor.Request request) { return cliCommandExecutor.executeAsync(request); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandContext.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandContext.java index 98e6b7adee33..3afa1cc4b74b 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandContext.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandContext.java @@ -42,6 +42,17 @@ interface AiSlashCommandContext { String selectedProcessName(); + /** + * Describes the tool set currently sent to the model, for example {@code core (18 of 46 tools), mode auto}. + */ + String describeToolMode(); + + /** + * Switches the tool mode to {@code auto}, {@code core} or {@code full} and persists it. Returns {@code false} when + * the mode is not one of those values. + */ + boolean switchToolMode(String mode); + CompletableFuture<AiCliCommandExecutor.Result> executeCli(AiCliCommandExecutor.Request request); void cancelCli(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java index 12b1a3f6481d..9b4f580c83fd 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java @@ -68,6 +68,9 @@ final class AiSlashCommandRegistry { commands.add(new Descriptor( "model", List.of("m"), "Show or switch the AI model", "<model>", AiSlashCommandRegistry::executeModel)); + commands.add(new Descriptor( + "tools", List.of("t"), "Show or switch the tool set sent to the model", "[auto|core|full]", + AiSlashCommandRegistry::executeTools)); commands.add(new Descriptor( "clear", List.of("c"), "Clear the conversation", null, (context, arguments) -> { @@ -296,6 +299,17 @@ final class AiSlashCommandRegistry { return CommandResult.listModels(); } + private static CommandResult executeTools(AiSlashCommandContext context, String arguments) { + if (arguments.isBlank()) { + return CommandResult.system("Tool set: " + context.describeToolMode()); + } + String mode = arguments.trim().toLowerCase(); + if (!context.switchToolMode(mode)) { + return CommandResult.error("Unknown tool mode '" + arguments.trim() + "'. Use auto, core or full."); + } + return CommandResult.system("Tool set: " + context.describeToolMode()); + } + private static int firstWhitespace(String value) { for (int i = 0; i < value.length(); i++) { if (Character.isWhitespace(value.charAt(i))) { 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 542aba850790..c6ababc82217 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 @@ -39,6 +39,7 @@ final class TuiSettings { static final String PROP_AI_PROVIDER = "camel.tui.ai.provider"; static final String PROP_AI_MODEL = "camel.tui.ai.model"; static final String PROP_AI_URL = "camel.tui.ai.url"; + static final String PROP_AI_TOOLS = "camel.tui.ai.tools"; 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"; @@ -59,6 +60,7 @@ final class TuiSettings { private String aiProvider; private String aiModel; private String aiUrl; + private String aiTools; private String shellHistory; private String aiPromptHistory; private String confirmActions; @@ -154,6 +156,18 @@ final class TuiSettings { this.aiUrl = aiUrl; } + /** + * Which tui_* tools the AI panel sends to the model: {@code auto} (default: the core set for local providers, all + * tools otherwise), {@code core} or {@code full}. + */ + String getAiTools() { + return aiTools; + } + + void setAiTools(String aiTools) { + this.aiTools = aiTools; + } + String getShellHistory() { return shellHistory; } @@ -247,6 +261,7 @@ final class TuiSettings { settings.aiProvider = trimToNull(TuiUserConfig.read(PROP_AI_PROVIDER)); settings.aiModel = trimToNull(TuiUserConfig.read(PROP_AI_MODEL)); settings.aiUrl = trimToNull(TuiUserConfig.read(PROP_AI_URL)); + settings.aiTools = trimToNull(TuiUserConfig.read(PROP_AI_TOOLS)); 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)); @@ -277,6 +292,7 @@ final class TuiSettings { TuiUserConfig.write(PROP_AI_PROVIDER, aiProvider); TuiUserConfig.write(PROP_AI_MODEL, aiModel); TuiUserConfig.write(PROP_AI_URL, aiUrl); + TuiUserConfig.write(PROP_AI_TOOLS, aiTools); 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/TuiToolRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java index c1263c518536..03bbb0fc4caf 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 @@ -19,6 +19,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import dev.tamboui.buffer.Buffer; @@ -79,7 +80,19 @@ class TuiToolRegistry { } /** - * Returns all 42 tool definitions. The result is cached since it is immutable. + * The tools needed to answer questions and troubleshoot from the built-in AI panel. The remaining tools drive the + * screen (drawing, animation, key presses, tape recording, themes) and exist for external MCP agents. Every tool + * schema is sent on every request, and a local model pays for that in prompt-processing time, so the AI panel sends + * only this subset to local providers unless configured otherwise. + */ + static final Set<String> CORE_TOOLS = Set.of( + "tui_get_state", "tui_get_options", "tui_get_table", "tui_get_log", "tui_get_errors", + "tui_get_diagram", "tui_get_topology", "tui_get_processor_detail", "tui_catalog_doc", + "tui_get_history", "tui_get_spans", "tui_control", "tui_send_message", "tui_get_files", + "tui_get_readme", "tui_navigate", "tui_set_log_level", "tui_filter"); + + /** + * Returns all tool definitions. The result is cached since it is immutable. */ List<ToolDef> getToolDefinitions() { List<ToolDef> tools = cachedTools; @@ -91,6 +104,13 @@ class TuiToolRegistry { return tools; } + /** + * Returns only the {@link #CORE_TOOLS} definitions, in registry order. + */ + List<ToolDef> getCoreToolDefinitions() { + return getToolDefinitions().stream().filter(t -> CORE_TOOLS.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/AiPanelTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java index 41dee94aa795..9da0a15db472 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java @@ -728,6 +728,67 @@ class AiPanelTest { assertEquals("/clear-history", panel.inputBufferForTesting()); } + // ---- tool set and system prompt tests ---- + + @Test + void localProviderGetsCoreToolsAndHostedProviderGetsAll() { + AiPanel panel = new AiPanel(); + panel.setToolRegistryForTesting(new TuiToolRegistry(null)); + RecordingLlmClient client = new RecordingLlmClient("ok"); + panel.setClientForTesting(client); + + // auto mode: a hosted provider gets every tool + assertEquals(new TuiToolRegistry(null).getToolDefinitions().size(), panel.toolDefinitionsForTesting().size()); + assertTrue(panel.systemPromptForTesting().contains("tui_draw_shape")); + + // auto mode: a local provider only gets the core set, and the prompt no longer suggests drawing tools + client.withApiType(LlmClient.ApiType.ollama); + assertEquals(TuiToolRegistry.CORE_TOOLS.size(), panel.toolDefinitionsForTesting().size()); + assertTrue(panel.toolDefinitionsForTesting().stream() + .allMatch(def -> TuiToolRegistry.CORE_TOOLS.contains(def.name()))); + assertFalse(panel.systemPromptForTesting().contains("tui_draw_shape")); + assertTrue(panel.describeToolModeForTesting().startsWith("core (18 of ")); + } + + @Test + void explicitToolModeOverridesProviderDetection() { + AiPanel panel = new AiPanel(); + panel.setToolRegistryForTesting(new TuiToolRegistry(null)); + RecordingLlmClient client = new RecordingLlmClient("ok"); + client.withApiType(LlmClient.ApiType.ollama); + panel.setClientForTesting(client); + + panel.setToolModeForTesting("full"); + assertEquals(new TuiToolRegistry(null).getToolDefinitions().size(), panel.toolDefinitionsForTesting().size()); + + panel.setToolModeForTesting("core"); + client.withApiType(LlmClient.ApiType.openai); + assertEquals(TuiToolRegistry.CORE_TOOLS.size(), panel.toolDefinitionsForTesting().size()); + } + + @Test + void systemPromptIsStableAndDoesNotRepeatTheToolList() { + AiPanel panel = new AiPanel(); + panel.setToolRegistryForTesting(new TuiToolRegistry(null)); + panel.setClientForTesting(new RecordingLlmClient("ok")); + + String prompt = panel.systemPromptForTesting(); + + // the tool definitions already describe every tool, so the prompt must not list them again + assertFalse(prompt.contains("- tui_get_table:")); + assertFalse(prompt.contains("The user is monitoring")); + assertEquals(prompt, panel.systemPromptForTesting()); + } + + @Test + void normalizeToolModeAcceptsKnownValuesOnly() { + assertEquals("auto", AiPanel.normalizeToolMode(null)); + assertEquals("auto", AiPanel.normalizeToolMode(" ")); + assertEquals("core", AiPanel.normalizeToolMode("Core")); + assertEquals("full", AiPanel.normalizeToolMode("FULL")); + assertNull(AiPanel.normalizeToolMode("bogus")); + } + // ---- paste tests ---- @Test @@ -853,6 +914,16 @@ class AiPanelTest { static final class FakeSlashContext implements AiSlashCommandContext { + @Override + public String describeToolMode() { + return "full (46 of 46 tools), mode auto"; + } + + @Override + public boolean switchToolMode(String mode) { + return true; + } + boolean exitRequested; boolean cancelRequested; boolean providerSwitchRequested; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java index 37f18f37375f..30ecddcfaba9 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java @@ -33,7 +33,9 @@ class AiSlashCommandRegistryTest { void descriptorsKeepStableOrder() { AiSlashCommandRegistry registry = AiSlashCommandRegistry.defaults(); - assertEquals(List.of("help", "provider", "model", "clear", "clear-history", "close", "quit", "run", "infra", "send"), + assertEquals( + List.of("help", "provider", "model", "tools", "clear", "clear-history", "close", "quit", "run", "infra", + "send"), registry.descriptors().stream().map(AiSlashCommandRegistry.Descriptor::name).toList()); } @@ -73,7 +75,7 @@ class AiSlashCommandRegistryTest { void completionsIncludeAllCommandsForBareSlash() { AiSlashCommandRegistry registry = AiSlashCommandRegistry.defaults(); - assertEquals(10, registry.completionsFor("/").size()); + assertEquals(11, registry.completionsFor("/").size()); assertFalse(registry.completionsFor("/").stream().anyMatch(descriptor -> "exit".equals(descriptor.name()))); } @@ -272,8 +274,71 @@ class AiSlashCommandRegistryTest { } } + @Test + void toolsWithoutArgumentsDescribesCurrentToolSet() { + AiSlashCommandRegistry registry = AiSlashCommandRegistry.defaults(); + ToolModeContext context = new ToolModeContext(); + + AiSlashCommandRegistry.CommandResult result = registry.execute("/tools", context); + + assertEquals("Tool set: core (18 of 46 tools), mode auto", result.text()); + assertNull(context.switchedTo); + } + + @Test + void toolsSwitchesModeCaseInsensitively() { + AiSlashCommandRegistry registry = AiSlashCommandRegistry.defaults(); + ToolModeContext context = new ToolModeContext(); + + AiSlashCommandRegistry.CommandResult result = registry.execute("/t Full", context); + + assertEquals("full", context.switchedTo); + assertEquals(AiRole.SYSTEM, result.role()); + } + + @Test + void toolsRejectsUnknownMode() { + AiSlashCommandRegistry registry = AiSlashCommandRegistry.defaults(); + ToolModeContext context = new ToolModeContext(); + + AiSlashCommandRegistry.CommandResult result = registry.execute("/tools bogus", context); + + assertEquals(AiRole.ERROR, result.role()); + assertTrue(result.text().contains("bogus")); + assertNull(context.switchedTo); + } + + private static final class ToolModeContext extends NoopSlashContext { + + private String switchedTo; + + @Override + public String describeToolMode() { + return "core (18 of 46 tools), mode auto"; + } + + @Override + public boolean switchToolMode(String mode) { + if (!List.of("auto", "core", "full").contains(mode)) { + return false; + } + switchedTo = mode; + return true; + } + } + private static class NoopSlashContext implements AiSlashCommandContext { + @Override + public String describeToolMode() { + return ""; + } + + @Override + public boolean switchToolMode(String mode) { + return false; + } + @Override public void closePanel() { } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java index 53da3f8a7649..00ba4ad323fe 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java @@ -63,6 +63,7 @@ class TuiSettingsTest { settings.setAiProvider("gemini"); settings.setAiModel("gemini-3.5-flash"); settings.setAiUrl("https://generativelanguage.googleapis.com"); + settings.setAiTools("core"); settings.setShellHistory("25"); settings.setAiPromptHistory("50"); settings.setPanelPosition("top"); @@ -76,6 +77,7 @@ class TuiSettingsTest { assertThat(loaded.getAiProvider()).isEqualTo("gemini"); assertThat(loaded.getAiModel()).isEqualTo("gemini-3.5-flash"); assertThat(loaded.getAiUrl()).isEqualTo("https://generativelanguage.googleapis.com"); + assertThat(loaded.getAiTools()).isEqualTo("core"); assertThat(loaded.getShellHistory()).isEqualTo("25"); assertThat(loaded.getAiPromptHistory()).isEqualTo("50"); assertThat(loaded.getPanelPosition()).isEqualTo("top"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryCoreToolsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryCoreToolsTest.java new file mode 100644 index 000000000000..b38d749ce48f --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryCoreToolsTest.java @@ -0,0 +1,61 @@ +/* + * 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.Set; +import java.util.stream.Collectors; + +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 TuiToolRegistryCoreToolsTest { + + @Test + void everyCoreToolExistsInTheRegistry() { + TuiToolRegistry registry = new TuiToolRegistry(null); + Set<String> all = registry.getToolDefinitions().stream() + .map(TuiToolRegistry.ToolDef::name).collect(Collectors.toSet()); + + assertTrue(all.containsAll(TuiToolRegistry.CORE_TOOLS), + "core tools missing from registry: " + TuiToolRegistry.CORE_TOOLS.stream() + .filter(name -> !all.contains(name)).toList()); + } + + @Test + void coreDefinitionsAreTheCoreSubsetInRegistryOrder() { + TuiToolRegistry registry = new TuiToolRegistry(null); + List<TuiToolRegistry.ToolDef> core = registry.getCoreToolDefinitions(); + List<TuiToolRegistry.ToolDef> all = registry.getToolDefinitions(); + + assertEquals(TuiToolRegistry.CORE_TOOLS.size(), core.size()); + assertTrue(core.size() < all.size()); + assertTrue(core.stream().allMatch(def -> TuiToolRegistry.CORE_TOOLS.contains(def.name()))); + assertEquals(all.stream().filter(def -> TuiToolRegistry.CORE_TOOLS.contains(def.name())).toList(), core); + } + + @Test + void coreSetLeavesOutScreenAutomationTools() { + Set<String> automation = Set.of("tui_draw", "tui_draw_shape", "tui_animate", "tui_send_keys", "tui_sleep", + "tui_tape_start", "tui_canvas_open", "tui_set_theme"); + + assertFalse(TuiToolRegistry.CORE_TOOLS.stream().anyMatch(automation::contains)); + } +}
