Skip to content

Commit ec17289

Browse files
committed
ephemeral autoprunning now based on 5 "actual human written user turns"
kill api request, and icons, highligting user feebdack
1 parent 49f835f commit ec17289

14 files changed

Lines changed: 127 additions & 27 deletions

File tree

src/main/java/uno/anahata/ai/Chat.java

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,21 @@ public class Chat {
8888
*/
8989
@Setter
9090
private boolean functionsEnabled = true;
91+
92+
/**
93+
* Flag to enable or disable server-side tools (e.g., Google Search).
94+
*/
95+
@Setter
96+
private boolean serverToolsEnabled = false;
9197

9298
private volatile boolean isProcessing = false;
9399
private volatile boolean shutdown = false;
94100
private Date startTime = new Date();
95101

96102
private final AtomicLong messageCounter = new AtomicLong(0);
97103

104+
private volatile Thread processingThread;
105+
98106
/**
99107
* Resets the sequential message counter to a specific value.
100108
* Useful when restoring a session from persistent storage.
@@ -131,11 +139,23 @@ public Chat(
131139
public void shutdown() {
132140
log.info("Shutting down Chat for session {}", config.getSessionId());
133141
this.shutdown = true;
142+
kill();
134143
if (executor != null && !executor.isShutdown()) {
135144
executor.shutdown();
136145
}
137146
}
138147

148+
/**
149+
* Interrupts the current processing thread, effectively cancelling any ongoing API call or tool execution.
150+
*/
151+
public void kill() {
152+
Thread t = processingThread;
153+
if (t != null) {
154+
log.info("Killing active processing thread: {}", t.getName());
155+
t.interrupt();
156+
}
157+
}
158+
139159
/**
140160
* Adds a listener to be notified of changes in the conversation context.
141161
*
@@ -203,12 +223,17 @@ public void sendText(String message) {
203223
}
204224

205225
private ChatMessage buildChatMessage(Content content, GenerateContentResponseUsageMetadata usage, GroundingMetadata grounding) {
226+
return buildChatMessage(content, usage, grounding, false);
227+
}
228+
229+
private ChatMessage buildChatMessage(Content content, GenerateContentResponseUsageMetadata usage, GroundingMetadata grounding, boolean toolFeedback) {
206230
return ChatMessage.builder()
207231
.sequentialId(messageCounter.incrementAndGet())
208232
.modelId(config.getApi().getModelId())
209233
.content(content)
210234
.usageMetadata(usage)
211235
.groundingMetadata(grounding)
236+
.toolFeedback(toolFeedback)
212237
.build();
213238
}
214239

@@ -223,6 +248,7 @@ public void sendContent(Content content) {
223248
return;
224249
}
225250
isProcessing = true;
251+
processingThread = Thread.currentThread();
226252
statusManager.recordUserInputTime();
227253
statusManager.setStatus(ChatStatus.API_CALL_IN_PROGRESS);
228254
try {
@@ -231,12 +257,17 @@ public void sendContent(Content content) {
231257

232258
processModelResponseLoop();
233259
} catch (Exception e) {
234-
log.error("An unhandled exception occurred during the processing loop.", e);
260+
if (Thread.interrupted() || e.getCause() instanceof InterruptedException) {
261+
log.info("Processing loop interrupted/killed.");
262+
} else {
263+
log.error("An unhandled exception occurred during the processing loop.", e);
264+
}
235265
// If an exception bubbles all the way up, it's a critical failure.
236266
// The MAX_RETRIES_REACHED status would have already been set inside the loop.
237267
// We don't want the finally block to override it.
238268
} finally {
239269
isProcessing = false;
270+
processingThread = null;
240271
// Only reset to IDLE if we are not in a terminal error state.
241272
if (statusManager.getCurrentStatus() != ChatStatus.MAX_RETRIES_REACHED) {
242273
statusManager.setStatus(ChatStatus.IDLE_WAITING_FOR_USER);
@@ -246,14 +277,18 @@ public void sendContent(Content content) {
246277

247278
private void processModelResponseLoop() {
248279
while (true) {
249-
if (shutdown) {
250-
log.info("Shutdown flag detected. Breaking processing loop for session {}.", config.getSessionId());
280+
if (shutdown || Thread.interrupted()) {
281+
log.info("Shutdown or interrupt flag detected. Breaking processing loop for session {}.", config.getSessionId());
251282
break;
252283
}
253284

254285
List<Content> apiContext = buildApiContext(contextManager.getContext());
255286
GenerateContentResponse resp = sendToModelWithRetry(apiContext);
256287

288+
if (resp == null) {
289+
break; // Interrupted
290+
}
291+
257292
if (resp.candidates() == null || !resp.candidates().isPresent() || resp.candidates().get().isEmpty()) {
258293
log.warn("Received response with no candidates. Possibly due to safety filters. Breaking loop.");
259294
Content emptyContent = Content.builder().role("model").parts(Part.fromText("[No response from model]")).build();
@@ -302,7 +337,7 @@ private boolean processAndReloopForFunctionCalls(ChatMessage modelMessageWithCal
302337
String feedbackText = "User Feedback: " + feedback;
303338

304339
Content feedbackContent = Content.builder().role("user").parts(Part.fromText(feedbackText)).build();
305-
ChatMessage feedbackMessage = buildChatMessage(feedbackContent, null, null);
340+
ChatMessage feedbackMessage = buildChatMessage(feedbackContent, null, null, true);
306341
contextManager.add(feedbackMessage);
307342
return false;
308343
}
@@ -367,7 +402,7 @@ private boolean processAndReloopForFunctionCalls(ChatMessage modelMessageWithCal
367402
userFeedbackParts.addAll(extraUserParts);
368403

369404
Content feedbackContent = Content.builder().role("user").parts(userFeedbackParts).build();
370-
ChatMessage feedbackMessage = buildChatMessage(feedbackContent, null, null);
405+
ChatMessage feedbackMessage = buildChatMessage(feedbackContent, null, null, true);
371406
feedbackMessage.setDependencies(userFeedbackDependencies); // Set dependencies after creation
372407
messagesToAdd.add(feedbackMessage);
373408
}
@@ -386,6 +421,9 @@ private GenerateContentResponse sendToModelWithRetry(List<Content> context) {
386421
long backoffAmount = 0;
387422

388423
for (int attempt = 0; attempt < maxRetries; attempt++) {
424+
if (Thread.interrupted()) {
425+
return null;
426+
}
389427
Client client = getGoogleGenAIClient();
390428
try {
391429
statusManager.setStatus(ChatStatus.API_CALL_IN_PROGRESS);
@@ -427,6 +465,9 @@ private GenerateContentResponse sendToModelWithRetry(List<Content> context) {
427465

428466
return ret;
429467
} catch (Exception e) {
468+
if (Thread.interrupted() || e.getCause() instanceof InterruptedException) {
469+
return null;
470+
}
430471
log.warn("Api Error on attempt {}: {}", attempt, e.toString());
431472
String apiKey = client.apiKey();
432473
String apiKeyLast5 = StringUtils.right(apiKey, 5);
@@ -446,7 +487,7 @@ private GenerateContentResponse sendToModelWithRetry(List<Content> context) {
446487
Thread.sleep(backoffAmount);
447488
} catch (InterruptedException ie) {
448489
Thread.currentThread().interrupt();
449-
throw new RuntimeException("Chat was interrupted during retry delay.", ie);
490+
return null;
450491
}
451492
} else {
452493
log.error("Unknown error from Google's servers", e);

src/main/java/uno/anahata/ai/ChatMessage.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ public class ChatMessage {
8383
@Builder.Default
8484
private final Instant createdOn = Instant.now();
8585

86+
/**
87+
* Indicates if this message is a system-generated tool feedback message.
88+
* Tool feedback messages have the 'user' role but should not be counted
89+
* as actual user turns for pruning purposes.
90+
*/
91+
private final boolean toolFeedback;
92+
8693
/**
8794
* Performs a full graph traversal to find all parts connected to the startPart,
8895
* including the startPart itself. This is used to find the complete set of
@@ -153,4 +160,4 @@ public void addDependencies(Part sourcePart, List<Part> dependentParts) {
153160
log.info("Added {} new dependencies to source part {} in message {}. Total dependencies for part: {}",
154161
dependentParts.size(), sourcePart.functionCall().map(fc -> fc.name().orElse("Text/Blob")).orElse("Text/Blob"), sequentialId, existing.size());
155162
}
156-
}
163+
}

src/main/java/uno/anahata/ai/config/ConfigManager.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public GenerateContentConfig makeGenerateContentConfig() {
7373
builder
7474
.tools(chat.getToolManager().getFunctionTool())
7575
.toolConfig(chat.getToolManager().getToolConfig());
76-
} else {
76+
} else if (chat.isServerToolsEnabled()) {
7777
Tool googleTools = Tool.builder().googleSearch(GoogleSearch.builder().build()).build();
7878
builder.tools(googleTools);
7979
}

src/main/java/uno/anahata/ai/context/provider/spi/ChatStatusProvider.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public List<Part> getParts(Chat chat) {
4646
chatStatusBlock.append("- StatusManager: ").append(chat.getStatusManager()).append("\n");
4747
chatStatusBlock.append("- Session Start time: ").append(chat.getStartTime()).append("\n");
4848
//chatStatusBlock.append("- Live Workspace (auto attaches screen captures on every call) Enabled: ").append(chat.isLiveWorkspaceEnabled()).append("\n");
49-
chatStatusBlock.append("- Server Side Tools (like google search) Enabled: ").append(!chat.isFunctionsEnabled()).append("\n");
49+
chatStatusBlock.append("- Server Side Tools (like google search) Enabled: ").append(chat.isServerToolsEnabled()).append("\n");
5050
chatStatusBlock.append("- Local @AiToolMethod Tools (e.g. LocalFiles) Enabled: ").append(chat.isFunctionsEnabled()).append("\n");
5151
if (chat.getLatency() > 0) {
5252
chatStatusBlock.append("- Latency (last successfull user/model round trip): ").append(chat.getLatency()).append(" ms.\n");

src/main/java/uno/anahata/ai/context/pruning/ContextPruner.java

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -415,20 +415,20 @@ public void pruneEphemeralToolCalls(ToolManager toolManager) {
415415
}
416416
List<ChatMessage> context = contextManager.getContext();
417417

418-
// Find the index of the message that marks our cutoff point (2 user turns ago).
419-
List<Integer> userMessageIndices = new ArrayList<>();
418+
// Find the index of the message that marks our cutoff point (5 user turns ago).
419+
List<Integer> userTurnIndices = new ArrayList<>();
420420
for (int i = context.size() - 1; i >= 0; i--) {
421-
if (isUserMessage(context.get(i))) {
422-
userMessageIndices.add(i);
421+
if (isUserTurn(context.get(i))) {
422+
userTurnIndices.add(i);
423423
}
424424
}
425425

426426
// If there aren't enough user turns yet, there's nothing to prune.
427-
if (userMessageIndices.size() <= turnsToKeep) {
428-
log.info("Not enough user turns ({} <= {}). Aborting ephemeral pruning.", userMessageIndices.size(), turnsToKeep);
427+
if (userTurnIndices.size() <= turnsToKeep) {
428+
log.info("Not enough user turns ({} <= {}). Aborting ephemeral pruning.", userTurnIndices.size(), turnsToKeep);
429429
return;
430430
}
431-
int pruneCutoffIndex = userMessageIndices.get(turnsToKeep);
431+
int pruneCutoffIndex = userTurnIndices.get(turnsToKeep);
432432
log.info("Pruning cutoff index determined: {}. Messages older than this index will be scanned.", pruneCutoffIndex);
433433

434434
// === Phase 1: Comprehensive Context Scan ===
@@ -525,9 +525,11 @@ private boolean isFailedStatefulResponse(FunctionResponse fr, ToolManager toolMa
525525
}
526526

527527
/**
528-
* Helper to determine if a message is a user message.
528+
* Helper to determine if a message is an actual user turn (not system-generated feedback).
529529
*/
530-
private boolean isUserMessage(ChatMessage message) {
531-
return message.getContent() != null && "user".equals(message.getContent().role().orElse(null));
530+
private boolean isUserTurn(ChatMessage message) {
531+
return message.getContent() != null
532+
&& "user".equals(message.getContent().role().orElse(null))
533+
&& !message.isToolFeedback();
532534
}
533535
}

src/main/java/uno/anahata/ai/status/ChatStatus.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ public enum ChatStatus {
3535
this.description = description;
3636
}
3737

38+
/**
39+
* Checks if the current status represents a state that can be interrupted by the user.
40+
* @return true if interruptible, false otherwise.
41+
*/
42+
public boolean isInterruptible() {
43+
return this == API_CALL_IN_PROGRESS || this == WAITING_WITH_BACKOFF || this == TOOL_EXECUTION_IN_PROGRESS;
44+
}
45+
3846
@Override
3947
public String toString() {
4048
return displayName;

src/main/java/uno/anahata/ai/swing/ChatPanel.java

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ public class ChatPanel extends JPanel implements ContextListener, StatusListener
3939
private JToggleButton liveWorkspaceButton;
4040
private JButton saveSessionButton;
4141
private JButton loadSessionButton;
42-
private JToggleButton functionsButton;
42+
private JToggleButton localToolsButton;
43+
private JToggleButton serverToolsButton;
4344
private JComboBox<String> modelIdComboBox;
4445

4546
private final EditorKitProvider editorKitProvider;
@@ -98,9 +99,27 @@ private void initComponents() {
9899
clearButton.setToolTipText("Restart Chat");
99100
clearButton.addActionListener(e -> restartChat());
100101

101-
functionsButton = new JToggleButton(IconUtils.getIcon("functions.png"), true);
102-
functionsButton.setToolTipText("Enable / Disable Functions");
103-
functionsButton.addActionListener(e -> chat.setFunctionsEnabled(functionsButton.isSelected()));
102+
localToolsButton = new JToggleButton(IconUtils.getIcon("java.png"), chat.isFunctionsEnabled());
103+
localToolsButton.setToolTipText("Enable / Disable Local Tools (Java)");
104+
localToolsButton.addActionListener(e -> {
105+
boolean selected = localToolsButton.isSelected();
106+
chat.setFunctionsEnabled(selected);
107+
if (selected) {
108+
serverToolsButton.setSelected(false);
109+
chat.setServerToolsEnabled(false);
110+
}
111+
});
112+
113+
serverToolsButton = new JToggleButton(IconUtils.getIcon("google.png", 18), chat.isServerToolsEnabled());
114+
serverToolsButton.setToolTipText("Enable / Disable Server Tools (Google Search)");
115+
serverToolsButton.addActionListener(e -> {
116+
boolean selected = serverToolsButton.isSelected();
117+
chat.setServerToolsEnabled(selected);
118+
if (selected) {
119+
localToolsButton.setSelected(false);
120+
chat.setFunctionsEnabled(false);
121+
}
122+
});
104123

105124
liveWorkspaceButton = new JToggleButton(IconUtils.getIcon("compress.png"), true);
106125
liveWorkspaceButton.setToolTipText("Toggle Live Workspace View");
@@ -129,7 +148,8 @@ private void initComponents() {
129148
loadSessionButton.addActionListener(e -> loadSession());
130149

131150
toolbar.add(clearButton);
132-
toolbar.add(functionsButton);
151+
toolbar.add(localToolsButton);
152+
toolbar.add(serverToolsButton);
133153
toolbar.add(liveWorkspaceButton);
134154
toolbar.add(new JToolBar.Separator());
135155
toolbar.add(saveSessionButton);

src/main/java/uno/anahata/ai/swing/IconUtils.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,24 @@ public class IconUtils {
1717
* @return A scaled ImageIcon, or null if the resource is not found.
1818
*/
1919
public static ImageIcon getIcon(String name) {
20+
return getIcon(name, 24);
21+
}
22+
23+
/**
24+
* Loads an icon from the classpath resources and scales it to the specified size.
25+
*
26+
* @param name The name of the icon file.
27+
* @param size The size (width and height) to scale the icon to.
28+
* @return A scaled ImageIcon, or null if the resource is not found.
29+
*/
30+
public static ImageIcon getIcon(String name, int size) {
2031
try {
2132
URL resource = IconUtils.class.getResource("/icons/" + name);
2233
if (resource == null) {
2334
return null;
2435
}
2536
ImageIcon originalIcon = new ImageIcon(resource);
26-
Image scaledImage = originalIcon.getImage().getScaledInstance(24, 24, Image.SCALE_SMOOTH);
37+
Image scaledImage = originalIcon.getImage().getScaledInstance(size, size, Image.SCALE_SMOOTH);
2738
return new ImageIcon(scaledImage);
2839
} catch (Exception e) {
2940
return null;

src/main/java/uno/anahata/ai/swing/StatusPanel.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import java.text.SimpleDateFormat;
1515
import java.util.List;
1616
import javax.swing.BorderFactory;
17+
import javax.swing.JButton;
1718
import javax.swing.JComponent;
1819
import javax.swing.JLabel;
1920
import javax.swing.JPanel;
@@ -41,6 +42,7 @@ public class StatusPanel extends JPanel {
4142
private JPanel detailsPanel;
4243
private JLabel tokenDetailsLabel;
4344
private JToggleButton soundToggle;
45+
private JButton killButton;
4446

4547
public StatusPanel(ChatPanel parentPanel) {
4648
super(new BorderLayout(10, 2));
@@ -77,9 +79,15 @@ private void initComponents() {
7779
soundToggle.setSelected(!parentPanel.getConfig().isAudioFeedbackEnabled());
7880
soundToggle.addActionListener(e -> parentPanel.getConfig().setAudioFeedbackEnabled(!soundToggle.isSelected()));
7981

82+
killButton = new JButton(IconUtils.getIcon("kill.png", 16));
83+
killButton.setToolTipText("Interrupt current API request or tool execution");
84+
killButton.setVisible(false);
85+
killButton.addActionListener(e -> parentPanel.getChat().kill());
86+
8087
statusDisplayPanel.add(soundToggle);
8188
statusDisplayPanel.add(statusIndicator);
8289
statusDisplayPanel.add(statusLabel);
90+
statusDisplayPanel.add(killButton);
8391

8492
contextUsageBar = new ContextUsageBar(parentPanel);
8593

@@ -136,6 +144,9 @@ public void refresh() {
136144
}
137145
}
138146

147+
// Update Kill Button visibility
148+
killButton.setVisible(currentStatus.isInterruptible());
149+
139150
// 2. Refresh Context Usage Bar
140151
contextUsageBar.refresh();
141152

src/main/java/uno/anahata/ai/tools/ToolCallOutcome.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public String toFeedbackString(boolean dialogShown) {
6767
StringBuilder sb = new StringBuilder();
6868
sb.append("[").append(toolName).append(" id=").append(id).append(" ").append(statusLabel);
6969
if (StringUtils.isNotBlank(executionFeedback)) {
70-
sb.append(" User Feedback: '").append(executionFeedback).append("'");
70+
sb.append(" User Feedback: **'").append(executionFeedback).append("'**");
7171
}
7272
sb.append("]");
7373
return sb.toString();

0 commit comments

Comments
 (0)