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 d0091452c3892300f9f5c40b44847c9e812138a5 Author: Claus Ibsen <[email protected]> AuthorDate: Tue Sep 8 17:22:56 2026 +0200 CAMEL-24656: camel-jbang TUI - trim verbose tool descriptions and cap the AI panel history Shorten the descriptions and schemas of the largest tools (tui_draw, tui_draw_shape, tui_catalog_doc, tui_navigate, tui_get_state, tui_get_options, tui_get_status) without dropping any parameter; tui_draw no longer repeats the tui_draw_shape vocabulary, and tui_get_options no longer tells the model to call it first on every task. The full tool set drops from ~7.3k to ~6.6k tokens and the core set to ~2.7k. The model history is now bounded: a tool result is capped at 16k characters when it enters the history (the AI log keeps the full text), tool results from turns before the previous one are compacted to their first 400 characters once a turn is answered, and the oldest turns are dropped beyond 20 questions. Whole turns are removed so tool calls never lose their matching results. Co-Authored-By: Claude Fable 5.1 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 81 ++++++++++++- .../jbang/core/commands/tui/TuiToolRegistry.java | 130 +++++++-------------- .../commands/tui/AiPanelHistoryCompactionTest.java | 106 +++++++++++++++++ 3 files changed, 227 insertions(+), 90 deletions(-) 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 a640ec44cc02..a11279f6aace 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 @@ -72,6 +72,21 @@ import org.apache.camel.util.json.JsonObject; class AiPanel { private static final int MAX_ITERATIONS = 10; + /** + * Longest tool result handed to the model. The AI log keeps the full text; the model gets the head plus a note, + * because a single log or table dump can otherwise be larger than the whole system prompt. + */ + static final int MAX_TOOL_RESULT_CHARS = 16_000; + /** + * Tool results from turns before the previous one are shrunk to this many characters once the turn is answered. The + * model's own answer already summarises them, and the whole history is re-sent (and re-processed by a local model) + * on every request. + */ + static final int COMPACT_TOOL_RESULT_CHARS = 400; + /** + * Oldest turns are dropped beyond this many user questions in one conversation. + */ + static final int MAX_HISTORY_TURNS = 20; private static final int MAX_LOG_ENTRIES = 200; private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault()); @@ -1035,7 +1050,7 @@ class AiPanel { log(LogLevel.TOOL, toolCall.name(), toolCall.arguments().toJson()); String result = executeTuiTool(toolCall.name(), toolCall.arguments()); log(LogLevel.RESULT, toolCall.name(), result); - results.add(new LlmClient.ToolResult(toolCall.id(), result)); + results.add(new LlmClient.ToolResult(toolCall.id(), truncateToolResult(result))); } messages.add(LlmClient.Message.toolResults(results)); } else { @@ -1055,6 +1070,7 @@ class AiPanel { } scrollOffset = 0; messages.add(LlmClient.Message.assistantWithToolCalls(text, List.of())); + compactHistory(messages, MAX_HISTORY_TURNS, COMPACT_TOOL_RESULT_CHARS); return; } } @@ -1856,6 +1872,69 @@ class AiPanel { return defs; } + /** + * Caps a tool result before it enters the model history; the AI log keeps the full text. + */ + static String truncateToolResult(String result) { + if (result == null || result.length() <= MAX_TOOL_RESULT_CHARS) { + return result; + } + return result.substring(0, MAX_TOOL_RESULT_CHARS) + + "\n... [truncated, " + (result.length() - MAX_TOOL_RESULT_CHARS) + + " more characters; narrow the request (filter, limit, section) to see the rest]"; + } + + /** + * Keeps the model history bounded after a turn is answered: tool results from turns before the previous one are + * shrunk to their head, and the oldest turns are dropped beyond {@code maxTurns} user questions. The previous turn + * is kept intact so an immediate follow-up can still refer to what was just fetched. Whole turns are removed (user + * message through the final answer) so assistant tool calls never lose their matching results. + */ + static void compactHistory(List<LlmClient.Message> history, int maxTurns, int compactChars) { + if (history == null || history.isEmpty()) { + return; + } + List<Integer> userIndexes = new ArrayList<>(); + for (int i = 0; i < history.size(); i++) { + LlmClient.Message m = history.get(i); + if ("user".equals(m.role()) && m.toolCalls() == null && m.toolResults() == null) { + userIndexes.add(i); + } + } + if (userIndexes.size() > maxTurns) { + int keepFrom = userIndexes.get(userIndexes.size() - maxTurns); + history.subList(0, keepFrom).clear(); + int dropped = userIndexes.size() - maxTurns; + userIndexes = userIndexes.subList(dropped, userIndexes.size()).stream() + .map(index -> index - keepFrom).toList(); + } + // everything before the previous turn (i.e. before the second-last user message) is compacted + if (userIndexes.size() < 2) { + return; + } + int compactBefore = userIndexes.get(userIndexes.size() - 2); + for (int i = 0; i < compactBefore; i++) { + LlmClient.Message m = history.get(i); + if (m.toolResults() == null || m.toolResults().isEmpty()) { + continue; + } + boolean changed = false; + List<LlmClient.ToolResult> compacted = new ArrayList<>(m.toolResults().size()); + for (LlmClient.ToolResult tr : m.toolResults()) { + String content = tr.content(); + if (content != null && content.length() > compactChars) { + content = content.substring(0, compactChars) + + "\n... [earlier result compacted; call the tool again for the full data]"; + changed = true; + } + compacted.add(new LlmClient.ToolResult(tr.toolCallId(), content)); + } + if (changed) { + history.set(i, LlmClient.Message.toolResults(compacted)); + } + } + } + private String executeTuiTool(String name, JsonObject args) { if (toolRegistry == null) { return "Error: TUI tools not available"; 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 09bd8c1bcb21..1ab25da7511f 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 @@ -187,14 +187,9 @@ class TuiToolRegistry { Map.of("limit", propDef("integer", "Maximum number of events to return (default 50)"))))); tools.add(toToolDef(toolDef( "tui_get_state", - "Returns the current TUI navigation state: active tab, selected integration, " - + "and integration count. " - + "Includes a 'selection' field with structured metadata about the active list/table. " - + "captionVisible indicates if a caption overlay is on screen. " - + "keystrokesVisible indicates if the keystroke overlay is on. " - + "detailFocused (boolean, present on tabs with master/detail panels) indicates " - + "which panel has focus: true=detail panel, false=table panel. " - + "Press Tab to toggle focus. Up/Down and PgUp/PgDn operate on the focused panel.", + "Current TUI navigation state: active tab, selected integration and PID, integration count, the " + + "active list/table selection, overlay flags and (on master/detail tabs) which " + + "panel has focus.", Map.of()))); tools.add(toToolDef(toolDef( "tui_show_caption", @@ -208,20 +203,14 @@ class TuiToolRegistry { List.of("text")))); tools.add(toToolDef(toolDef( "tui_navigate", - "Navigates the TUI: switch tabs and/or select an integration. " - + "All parameters are optional — set whichever you want to change. " - + "Tab names: Overview, Log, Activity, Diagram, Routes, Endpoints, HTTP, Inspect, " - + "Circuit Breaker, Health, Spans, Process. " - + "Use 'route' to select a route in the Diagram topology, " - + "and 'node' to drill down into a route and select a specific processor/EIP node. " - + "Returns screen content and selection metadata after navigating.", - Map.of("tab", propDef("string", "Tab to switch to (e.g. 'Routes', 'Activity', 'Diagram')"), + "Changes what the user sees: switch tab, select an integration, select a route in the Diagram " + + "tab, or drill into a processor node. Every parameter is optional. Returns the screen " + + "and selection afterwards. Do not use it just to read data; the tui_get_* tools do that.", + Map.of("tab", propDef("string", "Tab to switch to, e.g. Routes, Log, Diagram (see tui_get_options)"), "integration", propDef("string", "Integration name or PID to select"), - "route", propDef("string", - "Route ID to select in the Diagram tab topology (e.g. 'order-dispatcher')"), + "route", propDef("string", "Route ID to select in the Diagram tab"), "node", propDef("string", - "Processor/EIP node ID to select within a drilled-down route (e.g. 'multicast1'). " - + "If 'route' is also provided, drills into that route first"))))); + "Processor/EIP node ID to select inside the route (drills into 'route' first when given)"))))); tools.add(toToolDef(toolDef( "tui_send_keys", @@ -239,13 +228,9 @@ class TuiToolRegistry { List.of("keys")))); tools.add(toToolDef(toolDef( "tui_get_options", - "IMPORTANT: Call this FIRST before any other tui_ tool when starting a new task. " - + "Returns all available tabs with descriptions and running integrations. " - + "Each tab description says what data it provides — match the user's question " - + "keywords to tab descriptions to find the right data source in one step " - + "(e.g. 'kafka offset' → find Kafka tab → tui_get_table(tab='Kafka')). " - + "This avoids wasting calls piecing data from logs, spans, and endpoints " - + "when a dedicated tab already has exactly the needed information.", + "Lists every tab with a description of the data it provides, plus the running integrations. " + + "Use it when unsure which tab holds the data for a question (e.g. 'kafka offset' " + + "-> Kafka tab), then read that tab with tui_get_table.", Map.of()))); tools.add(toToolDef(toolDef( "tui_wait_for_idle", @@ -281,36 +266,18 @@ class TuiToolRegistry { List.of("seconds")))); tools.add(toToolDef(toolDef( "tui_draw", - "Draws characters at specific screen coordinates as an overlay on top of the TUI. " - + "Use this to highlight areas, annotate the screen for the human, " - + "draw shapes, or create fun emoji art. " - + "All cells are sent in a single call to avoid chatty networking. " - + "Coordinates are 0-based and match the screen grid from tui_get_screen. " - + "Characters can be any unicode including emoji. " - + "The drawing overlays on top of existing content without modifying it. " - + "Use with tui_show_caption to explain what you drew.", + "Draws an overlay on top of the TUI screen in one call: many shapes (batch of the tui_draw_shape " + + "parameters) and/or individual characters, including emoji. Coordinates are 0-based and " + + "match tui_get_screen. Use with tui_show_caption to explain what you drew.", Map.of("cells", propDef("array", - "Array of cell objects to draw. Each cell has: " - + "x (integer, column), y (integer, row), " - + "char (string, character to draw), " - + "fg (string, optional foreground color: red/green/blue/yellow/cyan/magenta/white/gray/black), " - + "bg (string, optional background color, same values), " - + "bold (boolean, optional)"), + "Cell objects: x, y, char, optional fg/bg color name, optional bold"), "shapes", propDef("array", - "Array of shape objects to draw (batch mode). Each shape has: " - + "shape (string, required: box/highlight/underline/arrow-down/arrow-up/arrow-left/arrow-right/text), " - + "x (integer, column), y (integer, row), " - + "width (integer, for box/highlight/underline), " - + "height (integer, for box/highlight), " - + "length (integer, for arrows), " - + "text (string, for text shape), " - + "color (string: red/green/blue/yellow/cyan/magenta/white/gray/black). " - + "Use shapes instead of cells for high-level drawing in a single call."), + "Shape objects with the same fields as tui_draw_shape: shape, x, y, width, height, " + + "length, text, color"), "duration", propDef("integer", - "Auto-dismiss drawing after this many seconds. " - + "If omitted, drawing stays until cleared with tui_draw_clear or replaced by another tui_draw call."), + "Auto-dismiss after this many seconds; otherwise stays until tui_draw_clear or the next tui_draw"), "append", propDef("boolean", - "If true, add cells to the existing drawing instead of replacing it. Default false.")), + "Add to the existing drawing instead of replacing it (default false)")), List.of()))); tools.add(toToolDef(toolDef( "tui_draw_clear", @@ -320,13 +287,10 @@ class TuiToolRegistry { tools.add(toToolDef(toolDef( "tui_draw_shape", - "Draws a predefined shape on the TUI screen overlay. " - + "Much easier than constructing individual cells with tui_draw. " - + "Combine with tui_locate for precise positioning.", + "Draws one shape on the TUI screen overlay. Combine with tui_locate for precise positioning.", Map.of("shape", propDef("string", - "Shape to draw: box (rectangle border), highlight (background color on existing text like a marker pen), " - + "underline (horizontal line), arrow-down, arrow-up, arrow-left, arrow-right, " - + "text (draw text string at position)"), + "box (border), highlight (marker-pen background), underline, arrow-down, arrow-up, " + + "arrow-left, arrow-right, or text"), "x", propDef("integer", "X coordinate (column) of the shape origin"), "y", propDef("integer", "Y coordinate (row) of the shape origin"), "width", propDef("integer", "Width of the shape (for box, highlight, underline)"), @@ -334,11 +298,10 @@ class TuiToolRegistry { "length", propDef("integer", "Length of arrows"), "text", propDef("string", "Text content to draw (for text shape)"), "color", propDef("string", - "Color: red, green, blue, yellow, cyan, magenta, white, gray, black. Default: red for box/underline/arrow, yellow for highlight."), - "duration", - propDef("integer", "Auto-dismiss after this many seconds. If omitted, stays until cleared."), - "append", propDef("boolean", - "If true, add to existing drawing instead of replacing it. Default false.")), + "red, green, blue, yellow, cyan, magenta, white, gray or black (default red, " + + "yellow for highlight)"), + "duration", propDef("integer", "Auto-dismiss after this many seconds; otherwise stays until cleared"), + "append", propDef("boolean", "Add to the existing drawing instead of replacing it (default false)")), List.of("shape", "x", "y")))); tools.add(toToolDef(toolDef( @@ -395,14 +358,12 @@ class TuiToolRegistry { + "If omitted, uses the active tab."))))); tools.add(toToolDef(toolDef( "tui_get_status", - "Returns one top-level section of the integration's full status document, the same JSON the Camel " - + "CLI reads from ~/.camel/<pid>-status.json. Use it for data the tabs do not show: " - + "'context' (name, version, state, uptime in millis, startTimestamp, statistics), " - + "'runtime' (pid, directory, java version), 'healthChecks', 'properties', " - + "'main-configuration', 'routeController', 'services', 'transformers', 'rests', " - + "'consumers', 'producers', 'endpoints', 'dataSources', 'memory', 'threads', 'gc', " - + "'classLoading', 'trace', 'events'. Pass section='sections' to list what the " - + "document contains. Sections can be large, so request only the one you need.", + "One top-level section of the integration's full status document (~/.camel/<pid>-status.json). " + + "Use it for data no tab shows: context (name, version, state, uptime millis, " + + "startTimestamp, statistics), runtime (pid, directory, java), healthChecks, " + + "properties, main-configuration, routeController, services, transformers, rests, " + + "consumers, producers, endpoints, dataSources, memory, threads, gc, classLoading, " + + "trace, events. section='sections' lists them. Request only the section you need.", Map.of("section", propDef("string", "Top-level section name, or 'sections' to list the available names"), "pid", propDef("string", @@ -609,26 +570,17 @@ class TuiToolRegistry { tools.add(toToolDef(toolDef( "tui_catalog_doc", - "Get documentation for a Camel catalog artifact (component, data format, language, EIP) " - + "including description, options, and Maven coordinates. " - + "Use optionsFilter to search options by keyword (e.g., 'security', 'ssl', 'timeout'). " - + "This enables queries like 'what options are there on kafka about security'. " - + "Set includeDoc=true to get the full AsciiDoc documentation for deep-dive questions. " - + "Uses the Camel version from the selected integration.", - Map.of("name", propDef("string", - "Artifact name (e.g., kafka, json-jackson, simple, timer, choice, split)"), + "Camel catalog documentation for a component, data format, language or EIP: description, options " + + "and Maven coordinates, for the Camel version of the selected integration. " + + "Use optionsFilter for questions like 'which kafka options are about security'.", + Map.of("name", propDef("string", "Artifact name, e.g. kafka, json-jackson, simple, timer, choice, split"), "kind", propDef("string", - "Artifact kind: component, dataformat, language, or eip. " - + "If omitted, auto-detects by trying component first, then dataformat, then language, then eip."), - "includeOptions", propDef("boolean", - "Whether to include configuration options in the response (default: true). " - + "Set to false for a lightweight response with just metadata."), + "component, dataformat, language or eip; auto-detected in that order when omitted"), + "includeOptions", propDef("boolean", "Include the configuration options (default true)"), "includeDoc", propDef("boolean", - "Whether to include the full AsciiDoc documentation text in the response (default: false). " - + "Useful for deep-dive questions about usage, examples, and configuration patterns."), + "Include the full AsciiDoc page for usage examples and patterns (default false)"), "optionsFilter", propDef("string", - "Filter options by keyword in name or description (case-insensitive substring match). " - + "Only used when includeOptions is true.")), + "Case-insensitive keyword to match in option names or descriptions")), List.of("name")))); tools.add(toToolDef(toolDef( diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelHistoryCompactionTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelHistoryCompactionTest.java new file mode 100644 index 000000000000..a5e842baed3e --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelHistoryCompactionTest.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.camel.dsl.jbang.core.commands.LlmClient; +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AiPanelHistoryCompactionTest { + + private static final String BIG = "x".repeat(5000); + + /** One question answered with a tool call: user, assistant(tool call), tool result, assistant(answer). */ + private static List<LlmClient.Message> turn(String question, String toolResult) { + List<LlmClient.Message> turn = new ArrayList<>(); + turn.add(LlmClient.Message.user(question)); + turn.add(LlmClient.Message.assistantWithToolCalls(null, + List.of(new LlmClient.ToolCall("call-" + question, "tui_get_log", new JsonObject())))); + turn.add(LlmClient.Message.toolResults(List.of(new LlmClient.ToolResult("call-" + question, toolResult)))); + turn.add(LlmClient.Message.assistantWithToolCalls("answer to " + question, List.of())); + return turn; + } + + private static String toolResultContent(LlmClient.Message message) { + return message.toolResults().get(0).content(); + } + + @Test + void truncatesOversizedToolResultsButKeepsShortOnes() { + String small = "ok"; + assertSame(small, AiPanel.truncateToolResult(small)); + + String huge = "y".repeat(AiPanel.MAX_TOOL_RESULT_CHARS + 1234); + String truncated = AiPanel.truncateToolResult(huge); + + assertTrue(truncated.startsWith("y".repeat(AiPanel.MAX_TOOL_RESULT_CHARS))); + assertTrue(truncated.contains("[truncated, 1234 more characters")); + } + + @Test + void compactsToolResultsOfOlderTurnsButKeepsThePreviousTurnIntact() { + List<LlmClient.Message> history = new ArrayList<>(); + history.addAll(turn("q1", BIG)); + history.addAll(turn("q2", BIG)); + history.addAll(turn("q3", BIG)); + + AiPanel.compactHistory(history, 20, 400); + + assertEquals(12, history.size()); + String first = toolResultContent(history.get(2)); + assertTrue(first.startsWith("x".repeat(400))); + assertTrue(first.contains("[earlier result compacted")); + // the previous turn (q2) and the current turn (q3) keep their full results + assertEquals(BIG, toolResultContent(history.get(6))); + assertEquals(BIG, toolResultContent(history.get(10))); + // structure is untouched: the tool call ids still match their results + assertEquals("call-q1", history.get(2).toolResults().get(0).toolCallId()); + } + + @Test + void dropsWholeOldestTurnsBeyondTheLimit() { + List<LlmClient.Message> history = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + history.addAll(turn("q" + i, "r" + i)); + } + + AiPanel.compactHistory(history, 3, 400); + + assertEquals(12, history.size()); + assertEquals("q3", history.get(0).content()); + assertEquals("user", history.get(0).role()); + assertEquals("answer to q5", history.get(11).content()); + } + + @Test + void singleTurnAndEmptyHistoryAreLeftAlone() { + List<LlmClient.Message> history = new ArrayList<>(turn("q1", BIG)); + + AiPanel.compactHistory(history, 20, 400); + AiPanel.compactHistory(new ArrayList<>(), 20, 400); + AiPanel.compactHistory(null, 20, 400); + + assertEquals(BIG, toolResultContent(history.get(2))); + } +}
