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 c85ae36949f00dda50adf3ea0d1af459b554eccf Author: Claus Ibsen <[email protected]> AuthorDate: Tue Sep 8 22:46:19 2026 +0200 camel-jbang-plugin-tui: AI panel defers history compaction on local endpoints After each answered turn the panel shrank older tool results to 400 chars. That saves counted tokens, which is what a hosted API bills for, but a local server (Ollama) keeps the KV cache of the previous request: a request that only extends the conversation pays for its new tokens (~0.2s), while rewriting an earlier message forces the tail from that point to be processed again (measured 1-2s per question with qwen3.6:35b-a3b on an M4 Pro, which also cannot reuse a partial prefix due to its hybrid attention). With a local endpoint the history is now left untouched until it is estimated to exceed 16k tokens, half the 32k window the client requests, and compacted as before beyond that. Hosted endpoints and /compact are unchanged. camel-jbang: AI log shows prompt-cache signal per answer (prefill/gen time, cached tokens) Token counts cannot tell whether a prompt was served from the provider's cache: Ollama reports the full prompt size either way. What the providers do reveal is now carried in TokenUsage and shown on the AI log's Response line: - Ollama: prompt_eval_duration and eval_duration, logged as 'prefill 0.2s, gen 2.6s'; a cached prompt is a near-zero prefill - OpenAI (prompt_tokens_details.cached_tokens), Anthropic (cache_read_input_tokens) and Gemini (cachedContentTokenCount): logged as 'cached 3.2k' The three-argument TokenUsage constructor is kept, so existing callers are unchanged. camel-jbang-plugin-tui: tui_get_status adds uptimeText next to the millis uptime The context section of the status document holds uptime in milliseconds without saying so, and the human form (3h13m) only exists in the dev console's text output and in what the TUI and camel ps compute for the screen. A small model reading the raw section guessed the unit and reported 13,800,803 ms as 13.8 seconds. The status tool now adds uptimeText next to every numeric uptime and says so in its description; route entries already carry text and are left alone. camel-console: context dev console does not count routes created by Kamelets routesTotal/routesStarted (and the Routes: line of the text output) came from the ManagedCamelContext MBean, which counts every route in the context, while the route dev console lists only JMX-managed routes, and routes created by a Kamelet are not registered with JMX by default. The two disagreed whenever a Kamelet was in use (3 routes vs 1 for a single route consuming from a Kamelet), which confused tooling and the TUI AI panel alike. The context console now counts routes the way the route console lists them: Kamelet-created routes are left out unless the management agent is configured to register them. The MBean attributes are unchanged. Upgrade guide note added; generated metadata and catalog regenerated. Co-Authored-By: Claude Fable 5.1 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../apache/camel/catalog/dev-consoles-openapi.json | 4 +- .../apache/camel/catalog/dev-consoles/context.json | 4 +- .../org/apache/camel/dev-console/context.json | 4 +- .../camel/impl/console/ContextDevConsole.java | 51 +++++++++++++++--- .../ContextDevConsoleKameletRoutesTest.java | 61 ++++++++++++++++++++++ .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 10 ++++ .../camel/dsl/jbang/core/commands/LlmClient.java | 52 +++++++++++++++--- .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 59 +++++++++++++++++++-- .../jbang/core/commands/tui/TuiToolRegistry.java | 28 +++++++++- .../commands/tui/AiPanelHistoryCompactionTest.java | 17 ++++++ .../dsl/jbang/core/commands/tui/AiPanelTest.java | 16 ++++++ .../tui/TuiToolRegistryUptimeTextTest.java | 60 +++++++++++++++++++++ 12 files changed, 342 insertions(+), 24 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json index 5a1454ef76ca..e3d001e3183e 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json @@ -1090,11 +1090,11 @@ "properties": { "routesTotal": { "type": "integer", - "description": "Total number of routes" + "description": "Total number of routes (routes created by Kamelets are not counted unless the management agent registers them)" }, "routesStarted": { "type": "integer", - "description": "Number of started routes" + "description": "Number of started routes (routes created by Kamelets are not counted unless the management agent registers them)" }, "load01": { "type": "string", diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/context.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/context.json index abf65937f19a..a5f047a34731 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/context.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/context.json @@ -56,11 +56,11 @@ "properties": { "routesTotal": { "type": "integer", - "description": "Total number of routes" + "description": "Total number of routes (routes created by Kamelets are not counted unless the management agent registers them)" }, "routesStarted": { "type": "integer", - "description": "Number of started routes" + "description": "Number of started routes (routes created by Kamelets are not counted unless the management agent registers them)" }, "load01": { "type": "string", diff --git a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/context.json b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/context.json index abf65937f19a..a5f047a34731 100644 --- a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/context.json +++ b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/context.json @@ -56,11 +56,11 @@ "properties": { "routesTotal": { "type": "integer", - "description": "Total number of routes" + "description": "Total number of routes (routes created by Kamelets are not counted unless the management agent registers them)" }, "routesStarted": { "type": "integer", - "description": "Number of started routes" + "description": "Number of started routes (routes created by Kamelets are not counted unless the management agent registers them)" }, "load01": { "type": "string", diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/ContextDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/ContextDevConsole.java index 64f08a622c65..29a8d90fbea6 100644 --- a/core/camel-console/src/main/java/org/apache/camel/impl/console/ContextDevConsole.java +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/ContextDevConsole.java @@ -23,13 +23,19 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import org.apache.camel.CamelContext; import org.apache.camel.ContextEvents; +import org.apache.camel.Route; +import org.apache.camel.ServiceStatus; import org.apache.camel.api.management.ManagedCamelContext; import org.apache.camel.api.management.mbean.ManagedCamelContextMBean; import org.apache.camel.clock.Clock; +import org.apache.camel.spi.ManagementAgent; +import org.apache.camel.spi.ManagementStrategy; import org.apache.camel.spi.Metadata; import org.apache.camel.spi.ReloadStrategy; import org.apache.camel.spi.ResourceReloadStrategy; +import org.apache.camel.spi.RouteController; import org.apache.camel.spi.annotations.DevConsole; import org.apache.camel.support.CamelContextHelper; import org.apache.camel.support.ExceptionHelper; @@ -52,8 +58,10 @@ public class ContextDevConsole extends AbstractDevConsole { } public record Statistics( - @Metadata(description = "Total number of routes") int routesTotal, - @Metadata(description = "Number of started routes") int routesStarted, + @Metadata(description = "Total number of routes (routes created by Kamelets are not counted unless the " + + "management agent registers them)") int routesTotal, + @Metadata(description = "Number of started routes (routes created by Kamelets are not counted unless the " + + "management agent registers them)") int routesStarted, @Metadata(description = "1 minute load average (only present when available)") String load01, @Metadata(description = "5 minute load average (only present when available)") String load05, @Metadata(description = "15 minute load average (only present when available)") String load15, @@ -120,9 +128,8 @@ public class ContextDevConsole extends AbstractDevConsole { if (mcc != null) { ManagedCamelContextMBean mb = mcc.getManagedCamelContext(); if (mb != null) { - int total = mb.getTotalRoutes(); - int started = mb.getStartedRoutes(); - sb.append(String.format("%n Routes: %s (started: %s)", total, started)); + int[] routes = countRoutes(getCamelContext()); + sb.append(String.format("%n Routes: %s (started: %s)", routes[0], routes[1])); int reloaded = 0; int reloadedFailed = 0; @@ -212,6 +219,37 @@ public class ContextDevConsole extends AbstractDevConsole { return JsonRecordSupport.toJsonObject(response); } + /** + * Counts routes the way the route console lists them. A Kamelet is implemented as a route inside the Kamelet, and + * such routes are not registered with JMX by default, so the route console does not show them; they are left out + * here too so the totals match what the routes list shows, unless the management agent is configured to register + * Kamelet routes (in which case both include them). The MBean totals, by contrast, count every route. + * + * @return total and started route counts + */ + static int[] countRoutes(CamelContext camelContext) { + boolean includeKamelet = false; + ManagementStrategy ms = camelContext.getManagementStrategy(); + ManagementAgent agent = ms != null ? ms.getManagementAgent() : null; + if (agent != null && Boolean.TRUE.equals(agent.getRegisterRoutesCreateByKamelet())) { + includeKamelet = true; + } + int total = 0; + int started = 0; + RouteController controller = camelContext.getRouteController(); + for (Route route : camelContext.getRoutes()) { + if (!includeKamelet && route.isCreatedByKamelet()) { + continue; + } + total++; + ServiceStatus status = controller.getRouteStatus(route.getRouteId()); + if (status != null && status.isStarted()) { + started++; + } + } + return new int[] { total, started }; + } + private Statistics buildStatistics(ManagedCamelContextMBean mb) { String load1 = getLoad1(mb); String load5 = getLoad5(mb); @@ -260,8 +298,9 @@ public class ContextDevConsole extends AbstractDevConsole { } Reload reload = new Reload(reloaded, reloadedFailed, lastError); + int[] routes = countRoutes(getCamelContext()); return new Statistics( - mb.getTotalRoutes(), mb.getStartedRoutes(), + routes[0], routes[1], hasLoad ? load1 : null, hasLoad ? load5 : null, hasLoad ? load15 : null, thp.isEmpty() ? null : thp, mb.getIdleSince(), mb.getExchangesTotal(), mb.getExchangesFailed(), mb.getExchangesInflight(), diff --git a/core/camel-console/src/test/java/org/apache/camel/impl/console/ContextDevConsoleKameletRoutesTest.java b/core/camel-console/src/test/java/org/apache/camel/impl/console/ContextDevConsoleKameletRoutesTest.java new file mode 100644 index 000000000000..25fc19ffe823 --- /dev/null +++ b/core/camel-console/src/test/java/org/apache/camel/impl/console/ContextDevConsoleKameletRoutesTest.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.impl.console; + +import org.apache.camel.ContextTestSupport; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.model.RouteDefinition; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Routes created by a Kamelet are internal to the Kamelet and not registered with JMX by default, so the route console + * does not list them; the context console's route counts must agree with that list rather than with the raw number of + * routes in the context. The statistics themselves need camel-management on the classpath, which this module's tests do + * not have, so the counting is asserted directly. + */ +public class ContextDevConsoleKameletRoutesTest extends ContextTestSupport { + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:app").routeId("app").to("mock:app"); + + RouteDefinition internal = from("direct:internal").routeId("kamelet-internal"); + internal.to("mock:internal"); + internal.setKamelet(true); + } + }; + } + + @Test + public void testKameletRoutesAreNotCountedAsRoutes() throws Exception { + Assertions.assertEquals(2, context.getRoutes().size(), "both routes exist in the context"); + Assertions.assertTrue(context.getRoute("kamelet-internal").isCreatedByKamelet()); + + int[] counts = ContextDevConsole.countRoutes(context); + Assertions.assertEquals(1, counts[0], "total leaves out the Kamelet route"); + Assertions.assertEquals(1, counts[1], "started leaves out the Kamelet route"); + + context.getRouteController().stopRoute("app"); + counts = ContextDevConsole.countRoutes(context); + Assertions.assertEquals(1, counts[0]); + Assertions.assertEquals(0, counts[1], "a stopped route still counts as total but not as started"); + } +} 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 d845030dfef8..117c059d53c8 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 @@ -91,6 +91,16 @@ The Event developer console now exposes the full structured JSON payload in the each event entry, while keeping the existing flat `type`, `timestamp`, `exchangeId`, and `message` fields for backwards compatibility. +=== camel-console + +The `context` developer console no longer counts routes created by Kamelets in its `routesTotal` and `routesStarted` +statistics (nor in the `Routes:` line of its text output). A Kamelet is implemented as a route inside the Kamelet, and +such routes are not registered with JMX by default, so the `route` developer console never listed them; the counts now +agree with that list. This affects consumers of the context status, such as the Camel JBang status document +(`~/.camel/<pid>-status.json`) and the Camel TUI. If the management agent is configured to register Kamelet routes +(`registerRoutesCreateByKamelet=true`), they are counted as before. The JMX `ManagedCamelContext` MBean attributes +`TotalRoutes` and `StartedRoutes` are unchanged and still count every route. + === camel-dynamic-router The `dynamic-router-control` endpoint no longer takes the subscription `predicate`, or the 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 0907110de505..de73bca11aed 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 @@ -124,14 +124,34 @@ public class LlmClient { } } - public record TokenUsage(int inputTokens, int outputTokens, int totalTokens) { + /** + * Token usage of one request, plus whatever the provider reveals about its prompt cache: hosted APIs report how + * many input tokens were served from cache ({@code cachedTokens}: OpenAI {@code cached_tokens}, Anthropic + * {@code cache_read_input_tokens}, Gemini {@code cachedContentTokenCount}), while Ollama reports no cache figure + * but does report how long prompt processing and generation took ({@code prefillMillis}, {@code generationMillis}), + * and a prompt served from its KV cache shows as a near-zero prefill. Zero means not reported. + */ + public record TokenUsage(int inputTokens, int outputTokens, int totalTokens, + int cachedTokens, long prefillMillis, long generationMillis) { public static final TokenUsage EMPTY = new TokenUsage(0, 0, 0); + public TokenUsage(int inputTokens, int outputTokens, int totalTokens) { + this(inputTokens, outputTokens, totalTokens, 0, 0, 0); + } + public TokenUsage add(TokenUsage other) { return new TokenUsage( inputTokens + other.inputTokens, outputTokens + other.outputTokens, - totalTokens + other.totalTokens); + totalTokens + other.totalTokens, + cachedTokens + other.cachedTokens, + prefillMillis + other.prefillMillis, + generationMillis + other.generationMillis); + } + + /** Whether the provider reported a prompt-cache figure or a timing split. */ + public boolean hasCacheSignal() { + return cachedTokens > 0 || prefillMillis > 0 || generationMillis > 0; } } @@ -846,7 +866,7 @@ public class LlmClient { if (total == 0) { total = input + output; } - return new TokenUsage(input, output, total); + return new TokenUsage(input, output, total, getIntValue(usageMetadata, "cachedContentTokenCount"), 0, 0); } private JsonObject sendGeminiRequest(String requestUrl, JsonObject body) { @@ -1040,6 +1060,7 @@ public class LlmClient { List<ToolCall> toolCalls = new ArrayList<>(); String[] doneReasonHolder = { null }; int[] tokenHolder = { 0, 0 }; + long[] durationHolder = { 0, 0 }; response.body().forEach(line -> { if (line.isBlank()) { @@ -1091,6 +1112,8 @@ public class LlmClient { doneReasonHolder[0] = chunk.getString("done_reason"); tokenHolder[0] = getIntValue(chunk, "prompt_eval_count"); tokenHolder[1] = getIntValue(chunk, "eval_count"); + durationHolder[0] = getLongValue(chunk, "prompt_eval_duration") / 1_000_000; + durationHolder[1] = getLongValue(chunk, "eval_duration") / 1_000_000; } } catch (Exception e) { // skip malformed chunks @@ -1105,7 +1128,9 @@ public class LlmClient { String stopReason = !toolCalls.isEmpty() ? "tool_calls" : (doneReasonHolder[0] != null ? doneReasonHolder[0] : "stop"); - TokenUsage usage = new TokenUsage(tokenHolder[0], tokenHolder[1], tokenHolder[0] + tokenHolder[1]); + TokenUsage usage = new TokenUsage( + tokenHolder[0], tokenHolder[1], tokenHolder[0] + tokenHolder[1], 0, + durationHolder[0], durationHolder[1]); if (verbose) { printer.println("[verbose] Streamed Ollama: text=" + (text != null ? truncateVerbose(text) : "null") + ", toolCalls=" + toolCalls.size() + ", doneReason=" + doneReasonHolder[0] @@ -1453,7 +1478,10 @@ public class LlmClient { int inputTokens = getIntValue(response, "prompt_eval_count"); int outputTokens = getIntValue(response, "eval_count"); - TokenUsage usage = new TokenUsage(inputTokens, outputTokens, inputTokens + outputTokens); + TokenUsage usage = new TokenUsage( + inputTokens, outputTokens, inputTokens + outputTokens, 0, + getLongValue(response, "prompt_eval_duration") / 1_000_000, + getLongValue(response, "eval_duration") / 1_000_000); if (verbose) { printer.println("[verbose] Parsed Ollama: text=" + (content != null ? truncateVerbose(content) : "null") @@ -1502,10 +1530,12 @@ public class LlmClient { int prompt = getIntValue(usage, "prompt_tokens"); int completion = getIntValue(usage, "completion_tokens"); int total = getIntValue(usage, "total_tokens"); + int cached = usage.get("prompt_tokens_details") instanceof JsonObject details + ? getIntValue(details, "cached_tokens") : 0; if (total == 0) { total = prompt + completion; } - return new TokenUsage(prompt, completion, total); + return new TokenUsage(prompt, completion, total, cached, 0, 0); } private TokenUsage extractAnthropicUsage(JsonObject response) { @@ -1515,7 +1545,7 @@ public class LlmClient { } int input = getIntValue(usage, "input_tokens"); int output = getIntValue(usage, "output_tokens"); - return new TokenUsage(input, output, input + output); + return new TokenUsage(input, output, input + output, getIntValue(usage, "cache_read_input_tokens"), 0, 0); } private static int getIntValue(JsonObject obj, String key) { @@ -1526,6 +1556,14 @@ public class LlmClient { return 0; } + private static long getLongValue(JsonObject obj, String key) { + Object val = obj.get(key); + if (val instanceof Number n) { + return n.longValue(); + } + return 0; + } + private String extractOpenAiContent(JsonObject response) { if (response == null) { return null; 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 7bad44131449..fc3561aba9f4 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 @@ -29,6 +29,7 @@ import java.util.Deque; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -96,6 +97,16 @@ class AiPanel { * Oldest turns are dropped beyond this many user questions in one conversation. */ static final int MAX_HISTORY_TURNS = 20; + /** + * With a local endpoint the history is left untouched until it is estimated to exceed this many tokens. A local + * server (Ollama) keeps the KV cache of the previous request, so a request that merely extends the conversation + * only pays for its new tokens, whereas rewriting an earlier message forces the whole tail from that point to be + * processed again (measured at one to two seconds per question with a 35B MoE model on Apple silicon, against 0.2s + * when the history is untouched). Compacting saves counted tokens, which is what a hosted API bills for, but costs + * time locally, so it is deferred until the context actually needs the room: half of the 32k window the client + * requests from Ollama, leaving space for the static prefix and the current turn's tool results. + */ + static final int LOCAL_HISTORY_BUDGET_TOKENS = 16_000; private static final int MAX_LOG_ENTRIES = 200; private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault()); @@ -1135,7 +1146,8 @@ class AiPanel { String tokenInfo = totalUsage.totalTokens() > 0 ? ", " + LlmClient.formatTokens(totalUsage.totalTokens()) + " tokens" : ""; - log(LogLevel.RESPONSE, "Response (" + elapsed + "s" + tokenInfo + ")", text); + log(LogLevel.RESPONSE, "Response (" + elapsed + "s" + tokenInfo + describeCacheSignal(totalUsage) + ")", + text); } else { String err = "Empty response from LLM."; conversation.add(new ConversationEntry(AiRole.ERROR, err)); @@ -1143,7 +1155,7 @@ class AiPanel { } scrollOffset = 0; messages.add(LlmClient.Message.assistantWithToolCalls(text, List.of())); - compactHistory(messages, MAX_HISTORY_TURNS, COMPACT_TOOL_RESULT_CHARS); + compactHistoryAfterTurn(); return; } } @@ -1162,7 +1174,48 @@ class AiPanel { // keep the history consistent: the turn ends without an answer, so the next question starts fresh from here messages.add(LlmClient.Message.assistantWithToolCalls( "(no answer: the iteration limit was reached while calling tools)", List.of())); - compactHistory(messages, MAX_HISTORY_TURNS, COMPACT_TOOL_RESULT_CHARS); + compactHistoryAfterTurn(); + } + + /** + * Compacts the history after a turn unless the endpoint is local and the history is still within + * {@link #LOCAL_HISTORY_BUDGET_TOKENS}; see there for why rewriting history is the slower choice locally. + * {@code /compact} bypasses this and always compacts. + */ + private void compactHistoryAfterTurn() { + boolean local = client != null && client.isLocalEndpoint(); + if (shouldCompactAfterTurn(local, historyChars(messages))) { + compactHistory(messages, MAX_HISTORY_TURNS, COMPACT_TOOL_RESULT_CHARS); + } + } + + static boolean shouldCompactAfterTurn(boolean localEndpoint, long historyChars) { + return !localEndpoint || estimateTokens(historyChars) > LOCAL_HISTORY_BUDGET_TOKENS; + } + + /** + * What the provider revealed about its prompt cache for the request(s) of a question, for the AI log: with Ollama + * the time spent on prompt processing versus generation (a cached prompt shows as a near-zero prefill even though + * the token count always reports the full prompt), with hosted APIs how many input tokens came from the cache. + * Empty when the provider reported nothing. + */ + static String describeCacheSignal(LlmClient.TokenUsage usage) { + if (usage == null || !usage.hasCacheSignal()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + if (usage.prefillMillis() > 0 || usage.generationMillis() > 0) { + sb.append(", prefill ").append(formatSeconds(usage.prefillMillis())) + .append(", gen ").append(formatSeconds(usage.generationMillis())); + } + if (usage.cachedTokens() > 0) { + sb.append(", cached ").append(LlmClient.formatTokens(usage.cachedTokens())); + } + return sb.toString(); + } + + private static String formatSeconds(long millis) { + return String.format(Locale.ROOT, "%.1fs", millis / 1000.0); } private static String summarize(String text, int max) { 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 8b58e480f158..043e9ef58d31 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 @@ -36,6 +36,7 @@ import org.apache.camel.tooling.model.ComponentModel; import org.apache.camel.tooling.model.DataFormatModel; import org.apache.camel.tooling.model.EipModel; import org.apache.camel.tooling.model.LanguageModel; +import org.apache.camel.util.TimeUtils; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; import org.apache.camel.util.json.Jsoner; @@ -362,8 +363,9 @@ class TuiToolRegistry { tools.add(toToolDef(toolDef( "tui_get_status", "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, " + + "Use it for data no tab shows: context (name, version, state, uptime in millis " + + "with uptimeText human readable, 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.", @@ -1356,6 +1358,7 @@ class TuiToolRegistry { if (value == null) { return "Unknown section '" + section + "' for PID " + pid + ". Available: " + String.join(", ", sections); } + addUptimeText(value); JsonObject result = new JsonObject(); result.put("pid", pid); result.put("section", section); @@ -1363,6 +1366,27 @@ class TuiToolRegistry { return Jsoner.serialize(result); } + /** + * Adds a human-readable {@code uptimeText} ("3h13m") next to every numeric {@code uptime}, which the status + * document holds in milliseconds without saying so; a small model otherwise guesses the unit (13,800,803 was read + * as 13.8 seconds). The route entries already carry their uptime as text and are left alone. + */ + static void addUptimeText(Object value) { + if (value instanceof JsonObject jo) { + Object uptime = jo.get("uptime"); + if (uptime instanceof Number n && !jo.containsKey("uptimeText")) { + jo.put("uptimeText", TimeUtils.printDuration(n.longValue())); + } + for (Object child : jo.values()) { + addUptimeText(child); + } + } else if (value instanceof JsonArray arr) { + for (Object child : arr) { + addUptimeText(child); + } + } + } + private String callAction(Map<String, Object> args) { String action = (String) args.get("action"); if (action == null || action.isBlank()) { 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 index 48a9c250a592..9ae95529d22a 100644 --- 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 @@ -24,6 +24,7 @@ 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.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -46,6 +47,22 @@ class AiPanelHistoryCompactionTest { return message.toolResults().get(0).content(); } + @Test + void hostedEndpointsCompactAfterEveryTurn() { + assertTrue(AiPanel.shouldCompactAfterTurn(false, 0)); + assertTrue(AiPanel.shouldCompactAfterTurn(false, 1_000)); + } + + @Test + void localEndpointsDeferCompactionUntilTheHistoryBudgetIsExceeded() { + long budgetChars = (long) AiPanel.LOCAL_HISTORY_BUDGET_TOKENS * 4; + // an untouched history keeps the local server's KV cache valid, so nothing is rewritten while it fits + assertFalse(AiPanel.shouldCompactAfterTurn(true, 0)); + assertFalse(AiPanel.shouldCompactAfterTurn(true, budgetChars)); + // once the history would crowd the context window, compaction resumes as for hosted endpoints + assertTrue(AiPanel.shouldCompactAfterTurn(true, budgetChars + 4_000)); + } + @Test void truncatesOversizedToolResultsButKeepsShortOnes() { String small = "ok"; 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 648eb1f72c0e..14b6798ab36a 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 @@ -1007,6 +1007,22 @@ class AiPanelTest { .anyMatch(e -> e.role() == AiRole.SYSTEM && e.text().contains("AI usage statistics reset"))); } + @Test + void responseLogShowsPrefillAndGenerationTimeForOllamaAndCachedTokensForHostedApis() { + assertEquals("", AiPanel.describeCacheSignal(new LlmClient.TokenUsage(100, 10, 110))); + assertEquals("", AiPanel.describeCacheSignal(null)); + // Ollama: a cached prompt is a near-zero prefill; the token count alone cannot tell + assertEquals(", prefill 0.2s, gen 2.6s", + AiPanel.describeCacheSignal(new LlmClient.TokenUsage(4500, 130, 4630, 0, 170, 2600))); + // hosted API: cache hits are reported as tokens + assertEquals(", cached 3.2k", + AiPanel.describeCacheSignal(new LlmClient.TokenUsage(4500, 130, 4630, 3200, 0, 0))); + // several round trips add up + LlmClient.TokenUsage sum = new LlmClient.TokenUsage(4500, 30, 4530, 0, 170, 600) + .add(new LlmClient.TokenUsage(4800, 130, 4930, 0, 210, 2600)); + assertEquals(", prefill 0.4s, gen 3.2s", AiPanel.describeCacheSignal(sum)); + } + @Test void retryWithoutAQuestionIsRefused() { AiPanel panel = new AiPanel(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryUptimeTextTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryUptimeTextTest.java new file mode 100644 index 000000000000..09246e13ec5a --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryUptimeTextTest.java @@ -0,0 +1,60 @@ +/* + * 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 org.apache.camel.util.json.JsonArray; +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; + +class TuiToolRegistryUptimeTextTest { + + @Test + void addsHumanReadableUptimeNextToMillis() { + JsonObject context = new JsonObject(); + context.put("name", "mqtt"); + context.put("uptime", 13_800_803L); + + TuiToolRegistry.addUptimeText(context); + + assertEquals(13_800_803L, context.get("uptime"), "the millis value stays for callers that compute with it"); + assertEquals("3h50m", context.get("uptimeText")); + } + + @Test + void leavesTextUptimeAndUnrelatedValuesAlone() { + JsonObject route = new JsonObject(); + route.put("routeId", "route1"); + route.put("uptime", "3h56m"); + JsonArray routes = new JsonArray(); + routes.add(route); + JsonObject root = new JsonObject(); + root.put("routes", routes); + JsonObject nested = new JsonObject(); + nested.put("uptime", 65_000L); + root.put("nested", nested); + + TuiToolRegistry.addUptimeText(root); + + assertFalse(route.containsKey("uptimeText"), "route uptime is already text"); + assertEquals("1m5s", nested.get("uptimeText"), "nested objects are handled too"); + TuiToolRegistry.addUptimeText("not json"); + TuiToolRegistry.addUptimeText(null); + } +}
