diff --git a/.gitignore b/.gitignore index 616b8105..0fb3bb13 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ External Plug-in Libraries/ com.chabicht.code-intelligence/bin/ **/target/ +.m2/repository/ diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatView.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatView.java index 48b87b83..f694fd11 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatView.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/ChatView.java @@ -25,6 +25,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -156,6 +157,8 @@ public class ChatView extends ViewPart { private final ChatSettings settings = new ChatSettings(); private final ScheduledExecutorService messageRenderExecutor = Executors.newSingleThreadScheduledExecutor(); private final Map pendingMessageUpdates = new ConcurrentHashMap<>(); + private final AtomicLong chatSessionGeneration = new AtomicLong(); + private volatile boolean disposed; LocalResourceManager resources = new LocalResourceManager(JFaceResources.getResources()); @@ -402,11 +405,14 @@ private String renderBatchReexecuteActionHtml(MessageRenderSnapshot message) { } private String renderToolActionHtml(MessageRenderSnapshot message, String title, String alt, String label) { + boolean disabled = isChatFlowBusy(); + String effectiveTitle = disabled ? "Tool execution is currently busy" : title; + String disabledAttribute = disabled ? " disabled" : ""; String actionButtonHtml = String.format( - "", - StringEscapeUtils.escapeHtml4(title), message.getId(), getReexecuteIconBase64(), + StringEscapeUtils.escapeHtml4(effectiveTitle), message.getId(), disabledAttribute, getReexecuteIconBase64(), StringEscapeUtils.escapeHtml4(alt), StringEscapeUtils.escapeHtml4(label)); return "
" + actionButtonHtml + "
"; } @@ -598,47 +604,79 @@ private Map copyFunctionParamValues(Map { - BatchExecutionReport batchReport = functionCallSession.executePendingBatchesSequentially(); - boolean hasExecutedToolCalls = batchReport.getCallsExecuted() > 0; - logDebugBatchExecutionReport(batchReport); - - for (ChatMessage updatedMessage : batchReport.getUpdatedMessages()) { - updatedMessage.setMetadata("tool_execution_state", "completed"); - onMessageUpdated(updatedMessage); - } - if (!hasExecutedToolCalls) { - // Set text to "▶️" - btnSend.setText("\u25B6"); + final long generation = chatSessionGeneration.get(); + final ChatConversation callbackConversation = conversation; + final FunctionCallSession callbackSession = functionCallSession; + final AiModelConnection callbackConnection = connection; - connection = null; + callbackSession.executePendingBatchesSequentiallyInBackground() + .thenAccept(batchReport -> Display.getDefault().asyncExec(() -> { + if (!isCurrentChatSession(generation, callbackConversation, callbackSession)) { + return; + } - if (isDebugPromptLoggingEnabled()) { - Activator.logInfo(conversation.toString()); - } + if (batchReport.isCanceled()) { + callbackSession.clearPendingChanges(); + btnSend.setText("\u25B6"); + if (connection == callbackConnection) { + connection = null; + } + chat.markMessageFinished(message.getId()); + addConversationToHistory(); + return; + } - applyPendingChanges(); + boolean hasExecutedToolCalls = batchReport.getCallsExecuted() > 0; + logDebugBatchExecutionReport(batchReport); - addConversationToHistory(); - } else { - boolean applyChangesImmediately = !Activator.getDefault().getPreferenceStore() - .getBoolean(PreferenceConstants.CHAT_TOOLS_APPLY_DEFERRED_ENABLED); - if (applyChangesImmediately && functionCallSession.hasPendingChanges()) { - ChangeApplicationResult res = functionCallSession.applyPendingChanges(); - if (res != ChangeApplicationResult.SUCCESS) { - abortChat(); - return; + for (ChatMessage updatedMessage : batchReport.getUpdatedMessages()) { + updatedMessage.setMetadata("tool_execution_state", "completed"); + onMessageUpdated(updatedMessage); } - } - logDebugContinuationRequestBuilt(batchReport); - sendFunctionResult(); - } - Display.getDefault().asyncExec(() -> { - chat.markMessageFinished(message.getId()); - }); - }); + if (!hasExecutedToolCalls) { + // Set text to "▶️" + btnSend.setText("\u25B6"); + + if (connection == callbackConnection) { + connection = null; + } + + if (isDebugPromptLoggingEnabled()) { + Activator.logInfo(callbackConversation.toString()); + } + + applyPendingChanges(callbackSession, callbackConversation); + + addConversationToHistory(); + } else { + boolean applyChangesImmediately = !Activator.getDefault().getPreferenceStore() + .getBoolean(PreferenceConstants.CHAT_TOOLS_APPLY_DEFERRED_ENABLED); + if (applyChangesImmediately && callbackSession.hasPendingChanges()) { + ChangeApplicationResult res = callbackSession.applyPendingChanges(); + if (res != ChangeApplicationResult.SUCCESS) { + abortChat(); + return; + } + } + logDebugContinuationRequestBuilt(batchReport); + sendFunctionResult(callbackConversation, callbackConnection); + } + + chat.markMessageFinished(message.getId()); + })) + .exceptionally(t -> { + Activator.logError("Tool execution failed", t); + Display.getDefault().asyncExec(() -> { + if (!isCurrentChatSession(generation, callbackConversation, callbackSession)) { + return; + } + abortChat(); + chat.markMessageFinished(message.getId()); + }); + return null; + }); } }; @@ -773,21 +811,51 @@ private void reexecuteToolBatch(String messageUuidString) { return; } + if (isChatFlowBusy()) { + Log.logInfo("ChatView: Ignoring re-execute request because chat/tool execution is already running."); + return; + } + Log.logInfo("ChatView: Re-executing tool batch for message UUID: " + messageUuidString); - functionCallSession.clearPendingChanges(); - BatchExecutionReport report = functionCallSession.executeBatch(messageToReexecute); - messageToReexecute.setMetadata("tool_execution_state", "completed"); - logDebugBatchExecutionReport(report); + final long generation = chatSessionGeneration.get(); + final ChatConversation callbackConversation = conversation; + final FunctionCallSession callbackSession = functionCallSession; + callbackSession.clearPendingChanges(); + messageToReexecute.setMetadata("tool_execution_state", "queued"); + runOnUiThread(() -> btnSend.setText("\u23F9")); if (chatListener != null) { chatListener.onMessageUpdated(messageToReexecute); - } else { - Log.logError("ChatView: chatListener is null, cannot update message view for batch re-execute."); } - if (functionCallSession.hasPendingChanges()) { - functionCallSession.applyPendingChanges(); - } + callbackSession.executeBatchInBackground(messageToReexecute) + .thenAccept(report -> runOnUiThread(() -> { + if (!isCurrentChatSession(generation, callbackConversation, callbackSession)) { + return; + } + messageToReexecute.setMetadata("tool_execution_state", "completed"); + logDebugBatchExecutionReport(report); + + if (chatListener != null) { + chatListener.onMessageUpdated(messageToReexecute); + } else { + Log.logError("ChatView: chatListener is null, cannot update message view for batch re-execute."); + } + + if (!report.isCanceled() && callbackSession.hasPendingChanges()) { + callbackSession.applyPendingChanges(); + } + btnSend.setText("\u25B6"); + })) + .exceptionally(t -> { + Log.logError("ChatView: Failed to re-execute tool batch.", t); + runOnUiThread(() -> { + if (isCurrentChatSession(generation, callbackConversation, callbackSession)) { + btnSend.setText("\u25B6"); + } + }); + return null; + }); } private void reexecuteToolSummary(String summaryMessageUuidString) { @@ -806,35 +874,75 @@ private void reexecuteToolSummary(String summaryMessageUuidString) { return; } + if (isChatFlowBusy()) { + Log.logInfo("ChatView: Ignoring summary re-execute request because chat/tool execution is already running."); + return; + } + + final long generation = chatSessionGeneration.get(); + final ChatConversation callbackConversation = conversation; + final FunctionCallSession callbackSession = functionCallSession; + // 1. IMPORTANT: Clear any changes from the previous run. - functionCallSession.clearPendingChanges(); + callbackSession.clearPendingChanges(); List idsToReexecute = summaryMessage.getSummarizedToolCallIds(); Log.logInfo("Re-executing tool summary for " + idsToReexecute.size() + " tool calls."); - // 2. Re-process each tool call in the sequence + List messagesToReexecute = new ArrayList<>(); for (UUID messageId : idsToReexecute) { - ChatMessage messageToReexecute = conversation.getMessages().stream() + ChatMessage messageToReexecute = callbackConversation.getMessages().stream() .filter(m -> m.getId().equals(messageId)).findFirst().orElse(null); - - if (messageToReexecute != null && messageToReexecute.getFunctionCallBatch().isPresent()) { - BatchExecutionReport report = functionCallSession.executeBatch(messageToReexecute); - messageToReexecute.setMetadata("tool_execution_state", "completed"); - logDebugBatchExecutionReport(report); - if (chatListener != null) { - chatListener.onMessageUpdated(messageToReexecute); - } + if (messageToReexecute != null && messageToReexecute.getFunctionCallBatch().isPresent()) { + messageToReexecute.setMetadata("tool_execution_state", "queued"); + messagesToReexecute.add(messageToReexecute); + if (chatListener != null) { + chatListener.onMessageUpdated(messageToReexecute); } } + } - // 3. After all calls are re-processed, apply the newly accumulated changes. - // This will open the refactoring wizard with the new set of changes. - if (functionCallSession.hasPendingChanges()) { - functionCallSession.applyPendingChanges(); - } else { - Log.logInfo("Re-execution finished, but no pending changes were generated."); - // Optionally, add a message to the chat informing the user. + if (messagesToReexecute.isEmpty()) { + Log.logInfo("Re-execution skipped because no referenced tool call batches could be found."); + return; } + + runOnUiThread(() -> btnSend.setText("\u23F9")); + callbackSession.executeBatchesInBackground(messagesToReexecute) + .thenAccept(report -> runOnUiThread(() -> { + if (!isCurrentChatSession(generation, callbackConversation, callbackSession)) { + return; + } + logDebugBatchExecutionReport(report); + for (ChatMessage updatedMessage : report.getUpdatedMessages()) { + updatedMessage.setMetadata("tool_execution_state", "completed"); + if (chatListener != null) { + chatListener.onMessageUpdated(updatedMessage); + } + } + + if (report.isCanceled()) { + for (ChatMessage message : messagesToReexecute) { + if (chatListener != null) { + chatListener.onMessageUpdated(message); + } + } + } else if (callbackSession.hasPendingChanges()) { + callbackSession.applyPendingChanges(); + } else { + Log.logInfo("Re-execution finished, but no pending changes were generated."); + } + btnSend.setText("\u25B6"); + })) + .exceptionally(t -> { + Log.logError("ChatView: Failed to re-execute tool summary.", t); + runOnUiThread(() -> { + if (isCurrentChatSession(generation, callbackConversation, callbackSession)) { + btnSend.setText("\u25B6"); + } + }); + return null; + }); } private void clearAllPendingChanges() { @@ -844,12 +952,17 @@ private void clearAllPendingChanges() { } } - private void sendFunctionResult() { - if (connection != null && connection.isChatPending()) { - connection.abortChat(); + private void sendFunctionResult(ChatConversation conversationToContinue, AiModelConnection connectionToUse) { + if (connectionToUse == null || conversationToContinue == null) { + Log.logError("ChatView: Cannot send function result continuation because connection or conversation is null."); + return; } - connection.chat(conversation, settings.getMaxResponseTokens()); + if (connectionToUse.isChatPending()) { + connectionToUse.abortChat(); + } + + connectionToUse.chat(conversationToContinue, settings.getMaxResponseTokens()); } private void logDebugBatchQueuedInView(ChatMessage message) { @@ -1578,53 +1691,95 @@ public void setFocus() { } } + private boolean isChatFlowBusy() { + return (connection != null && connection.isChatPending()) + || (functionCallSession != null && functionCallSession.isToolExecutionRunning()); + } + private void sendMessageOrAbortChat() { + if (isChatFlowBusy()) { + boolean toolExecutionWasRunning = functionCallSession != null && functionCallSession.isToolExecutionRunning(); + abortChat(); + + // Preserve the old behavior for aborting model responses, but do not apply + // changes immediately while a tool job is being canceled. The job may not have + // observed cancellation yet, so applying here would race with pending changes + // still being produced by the background job. + if (!toolExecutionWasRunning) { + applyPendingChanges(); + } + return; + } + if (connection == null) { connection = ConnectionFactory.forChat(settings.getModel()); } - if (connection.isChatPending()) { - abortChat(); - // apply pending changes, if any were added so far. - // this will also add a message summarizing the changes. - applyPendingChanges(); - } else { - ChatMessage chatMessage = new ChatMessage(Role.USER, userInput.get()); + ChatMessage chatMessage = new ChatMessage(Role.USER, userInput.get()); - String consoleSelection = ConsolePageParticipant.getSelectedText(); - if (StringUtils.isNotBlank(consoleSelection)) { - Point selectionRange = Optional.ofNullable(ConsolePageParticipant.getSelectionRange()) - .orElse(new Point(0, 0)); - String consoleName = Optional.ofNullable(ConsolePageParticipant.getConsoleName()).orElse("Console Log"); - externallyAddedContext.add(new MessageContext(consoleName, RangeType.OFFSET, selectionRange.x, - selectionRange.x + selectionRange.y, consoleSelection)); - } + String consoleSelection = ConsolePageParticipant.getSelectedText(); + if (StringUtils.isNotBlank(consoleSelection)) { + Point selectionRange = Optional.ofNullable(ConsolePageParticipant.getSelectionRange()).orElse(new Point(0, 0)); + String consoleName = Optional.ofNullable(ConsolePageParticipant.getConsoleName()).orElse("Console Log"); + externallyAddedContext.add(new MessageContext(consoleName, RangeType.OFFSET, selectionRange.x, + selectionRange.x + selectionRange.y, consoleSelection)); + } + + externallyAddedContext.forEach(ctx -> addContextToMessageIfNotDuplicate(chatMessage, ctx)); + externallyAddedContext.clear(); + addSelectionAsContext(chatMessage); + + conversation.getOptions().put(REASONING_ENABLED, settings.isReasoningSupportedAndEnabled()); + conversation.getOptions().put(REASONING_BUDGET_TOKENS, settings.getReasoningTokens()); + conversation.getOptions().put(REASONING_EFFORT, settings.getEffectiveReasoningEffort()); + conversation.getOptions().put(TOOLS_ENABLED, settings.isToolsEnabled()); + conversation.getOptions().put(TOOL_PROFILE, settings.getToolProfile()); - externallyAddedContext.forEach(ctx -> addContextToMessageIfNotDuplicate(chatMessage, ctx)); - externallyAddedContext.clear(); - addSelectionAsContext(chatMessage); + conversation.addMessage(chatMessage, true); + connection.chat(conversation, settings.getMaxResponseTokens()); + userInput.set(""); - conversation.getOptions().put(REASONING_ENABLED, settings.isReasoningSupportedAndEnabled()); - conversation.getOptions().put(REASONING_BUDGET_TOKENS, settings.getReasoningTokens()); - conversation.getOptions().put(REASONING_EFFORT, settings.getEffectiveReasoningEffort()); - conversation.getOptions().put(TOOLS_ENABLED, settings.isToolsEnabled()); - conversation.getOptions().put(TOOL_PROFILE, settings.getToolProfile()); + // Set text to "⏹️" + btnSend.setText("\u23F9"); + } + + + private void runOnUiThread(Runnable runnable) { + Display display = Display.getDefault(); + if (display == null || display.isDisposed() || runnable == null) { + return; + } + if (Display.getCurrent() == display) { + runnable.run(); + } else { + display.asyncExec(runnable); + } + } - conversation.addMessage(chatMessage, true); - connection.chat(conversation, settings.getMaxResponseTokens()); - userInput.set(""); + private boolean isCurrentChatSession(long generation, ChatConversation expectedConversation, + FunctionCallSession expectedSession) { + return !disposed && chatSessionGeneration.get() == generation && conversation == expectedConversation + && functionCallSession == expectedSession && chat != null && !chat.isDisposed(); + } - // Set text to "⏹️" - btnSend.setText("\u23F9"); + private void resetToolSessionForNewConversation() { + FunctionCallSession oldSession = functionCallSession; + if (oldSession != null) { + oldSession.cancelToolExecution(); + if (!oldSession.isToolExecutionRunning()) { + oldSession.clearPendingChanges(); + } } + functionCallSession = new FunctionCallSession(); } private void abortChat() { if (connection != null) { connection.abortChat(); } + functionCallSession.cancelToolExecution(); - Display.getDefault().syncExec(() -> { + runOnUiThread(() -> { chat.markAllMessagesFinished(); // Set text to "▶️" @@ -1638,32 +1793,43 @@ private void abortChat() { public void editChat(String messageUuidString) { Display.getDefault().syncExec(() -> { - // Treat the conversation as new history-wise. - conversation.setConversationId(null); + if (isChatFlowBusy()) { + Log.logInfo("ChatView: Ignoring edit request because chat/tool execution is already running."); + return; + } UUID messageUuid = UUID.fromString(messageUuidString); - if (connection == null || !connection.isChatPending()) { - getExternallyAddedContext().clear(); - ChatConversation oldConvo = conversation; - - List messages = oldConvo.getMessages(); - ChatMessage msgToEdit = null; - for (int i = messages.size() - 1; i >= 0; i--) { - ChatMessage msg = messages.get(i); - if (msgToEdit == null) { - messages.remove(i); - } - if (messageUuid.equals(msg.getId())) { - msgToEdit = msg; - break; - } + getExternallyAddedContext().clear(); + + ChatConversation oldConvo = conversation; + List messages = oldConvo.getMessages(); + + int editIndex = -1; + for (int i = messages.size() - 1; i >= 0; i--) { + if (messageUuid.equals(messages.get(i).getId())) { + editIndex = i; + break; } + } + + if (editIndex < 0) { + Log.logError("ChatView: Cannot edit message. Message not found for UUID: " + messageUuidString); + return; + } - replaceChat(oldConvo); + ChatMessage msgToEdit = messages.get(editIndex); - userInput.set(msgToEdit.getContent()); - getExternallyAddedContext().addAll(msgToEdit.getContext()); + // Treat the conversation as new history-wise, but only after we know the edit is valid. + conversation.setConversationId(null); + + for (int i = messages.size() - 1; i >= editIndex; i--) { + messages.remove(i); } + + replaceChat(oldConvo); + + userInput.set(msgToEdit.getContent()); + getExternallyAddedContext().addAll(msgToEdit.getContext()); }); } @@ -1723,16 +1889,18 @@ private void addContextToMessageIfNotDuplicate(ChatMessage chatMessage, MessageC } private void clearChatInternal(ChatConversation replacement) { + chatSessionGeneration.incrementAndGet(); + if (connection != null) { connection.abortChat(); connection = null; } + resetToolSessionForNewConversation(); conversation.removeListener(chatListener); conversation = replacement; conversation.addListener(chatListener); externallyAddedContext.clear(); pendingMessageUpdates.clear(); - clearAllPendingChanges(); chat.reset(); userInput.set(""); @@ -2175,6 +2343,20 @@ private MessageContentWithReasoning splitThoughtsFromMessage(String content) { @Override public void dispose() { + disposed = true; + chatSessionGeneration.incrementAndGet(); + + if (connection != null) { + connection.abortChat(); + connection = null; + } + if (functionCallSession != null) { + functionCallSession.cancelToolExecution(); + if (!functionCallSession.isToolExecutionRunning()) { + functionCallSession.clearPendingChanges(); + } + } + pendingMessageUpdates.clear(); messageRenderExecutor.shutdownNow(); executorService.shutdownNow(); @@ -2192,17 +2374,25 @@ private void createPaperclipBase64() { } private void applyPendingChanges() { + applyPendingChanges(functionCallSession, conversation); + } + + private void applyPendingChanges(FunctionCallSession session, ChatConversation targetConversation) { + if (session == null || targetConversation == null) { + return; + } + // Apply pending changes after all function calls are done. - if (functionCallSession.hasPendingChanges()) { + if (session.hasPendingChanges()) { // 1. Identify the sequence of tool calls that just finished. - Set messagesWithPendingChanges = new HashSet<>(functionCallSession.getMessagesWithPendingChanges()); + Set messagesWithPendingChanges = new HashSet<>(session.getMessagesWithPendingChanges()); List toolCallSequence = new ArrayList<>(); - conversation.getMessages().stream().filter(m -> messagesWithPendingChanges.contains(m.getId())) + targetConversation.getMessages().stream().filter(m -> messagesWithPendingChanges.contains(m.getId())) .forEach(toolCallSequence::add); // 2. Create the new TOOL_SUMMARY message // Get the detailed summary from the session - String summaryContent = functionCallSession.getPendingChangesSummary(); + String summaryContent = session.getPendingChangesSummary(); ChatMessage summaryMessage = new ChatMessage(Role.TOOL_SUMMARY, summaryContent); // 3. Populate the summary message with the IDs of the calls @@ -2213,10 +2403,10 @@ private void applyPendingChanges() { } // 4. Add the summary message to the conversation - conversation.addMessage(summaryMessage, false); // false because it's a final message + targetConversation.addMessage(summaryMessage, false); // false because it's a final message // 5. Trigger the refactoring dialog as before - functionCallSession.applyPendingChanges(); + session.applyPendingChanges(); } } diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/tools/FunctionCallSession.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/tools/FunctionCallSession.java index a850cc60..fb691851 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/tools/FunctionCallSession.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/tools/FunctionCallSession.java @@ -7,11 +7,17 @@ import java.util.Map; import java.util.Map.Entry; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; import org.apache.commons.lang3.StringUtils; import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.Status; +import org.eclipse.core.runtime.jobs.Job; import org.eclipse.jface.dialogs.IDialogConstants; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; @@ -54,6 +60,7 @@ public static class BatchExecutionReport { private int callsExecuted; private int callsFailed; private final List updatedMessages = new ArrayList<>(); + private boolean canceled; public int getBatchesExecuted() { return batchesExecuted; @@ -79,6 +86,14 @@ public void setCallsFailed(int callsFailed) { this.callsFailed = callsFailed; } + public boolean isCanceled() { + return canceled; + } + + public void setCanceled(boolean canceled) { + this.canceled = canceled; + } + public List getUpdatedMessages() { return updatedMessages; } @@ -101,6 +116,8 @@ public List getUpdatedMessages() { private final Map pendingCreateFileChanges = new HashMap<>(); private final List messagesWithPendingChanges = new ArrayList<>(); private final List pendingBatchMessages = new ArrayList<>(); + private final Object executionLock = new Object(); + private Job currentExecutionJob; public FunctionCallSession() { // Create the real resource access @@ -181,27 +198,116 @@ public void enqueueBatch(ChatMessage assistantMessage) { } public BatchExecutionReport executePendingBatchesSequentially() { + return executePendingBatchesSequentially(new NullProgressMonitor()); + } + + public BatchExecutionReport executePendingBatchesSequentially(IProgressMonitor monitor) { if (pendingBatchMessages.isEmpty()) { return new BatchExecutionReport(); } List batchesToExecute = new ArrayList<>(pendingBatchMessages); pendingBatchMessages.clear(); - return executeBatchesSequentially(batchesToExecute); + return executeBatchesSequentially(batchesToExecute, monitor); } public BatchExecutionReport executeBatch(ChatMessage assistantMessage) { + return executeBatch(assistantMessage, new NullProgressMonitor()); + } + + public BatchExecutionReport executeBatch(ChatMessage assistantMessage, IProgressMonitor monitor) { if (assistantMessage == null || assistantMessage.getFunctionCallBatch().isEmpty()) { return new BatchExecutionReport(); } List batchesToExecute = new ArrayList<>(); batchesToExecute.add(assistantMessage); - return executeBatchesSequentially(batchesToExecute); + return executeBatchesSequentially(batchesToExecute, monitor); + } + + public CompletableFuture executePendingBatchesSequentiallyInBackground() { + return scheduleBatchExecution("Executing AI tool calls", this::executePendingBatchesSequentially); + } + + public CompletableFuture executeBatchInBackground(ChatMessage assistantMessage) { + return scheduleBatchExecution("Re-executing AI tool call batch", monitor -> executeBatch(assistantMessage, monitor)); + } + + public CompletableFuture executeBatchesInBackground(List assistantMessages) { + List batchesToExecute = assistantMessages != null ? new ArrayList<>(assistantMessages) : new ArrayList<>(); + return scheduleBatchExecution("Re-executing AI tool call batches", + monitor -> executeBatchesSequentially(batchesToExecute, monitor)); + } + + private CompletableFuture scheduleBatchExecution(String jobName, + Function execution) { + CompletableFuture future = new CompletableFuture<>(); + + synchronized (executionLock) { + if (currentExecutionJob != null) { + future.completeExceptionally(new IllegalStateException("Tool execution is already running.")); + return future; + } + + Job job = new Job(jobName) { + @Override + protected IStatus run(IProgressMonitor monitor) { + try { + BatchExecutionReport report = execution.apply(monitor); + if (monitor.isCanceled()) { + report.setCanceled(true); + } + if (report.isCanceled()) { + clearPendingChanges(); + } + future.complete(report); + return report.isCanceled() ? Status.CANCEL_STATUS : Status.OK_STATUS; + } catch (Throwable t) { + clearPendingChanges(); + Activator.logError("Error executing AI tool calls", t); + future.completeExceptionally(t); + return new Status(IStatus.ERROR, Activator.PLUGIN_ID, "Error executing AI tool calls", t); + } finally { + synchronized (executionLock) { + if (currentExecutionJob == this) { + currentExecutionJob = null; + } + } + } + } + }; + + currentExecutionJob = job; + job.setPriority(Job.LONG); + job.setUser(false); + job.schedule(); + } + + return future; + } + + public boolean isToolExecutionRunning() { + synchronized (executionLock) { + return currentExecutionJob != null; + } } - private BatchExecutionReport executeBatchesSequentially(List batchesToExecute) { + public void cancelToolExecution() { + Job jobToCancel; + synchronized (executionLock) { + jobToCancel = currentExecutionJob; + } + if (jobToCancel != null) { + jobToCancel.cancel(); + } + } + + private BatchExecutionReport executeBatchesSequentially(List batchesToExecute, + IProgressMonitor monitor) { BatchExecutionReport report = new BatchExecutionReport(); + if (monitor == null) { + monitor = new NullProgressMonitor(); + } if (batchesToExecute == null || batchesToExecute.isEmpty()) { return report; } @@ -215,6 +321,10 @@ private BatchExecutionReport executeBatchesSequentially(List batche int callsFailed = 0; for (ChatMessage assistantMessage : batchesToExecute) { + if (monitor.isCanceled()) { + report.setCanceled(true); + break; + } if (assistantMessage == null || assistantMessage.getFunctionCallBatch().isEmpty()) { continue; } @@ -236,24 +346,37 @@ private BatchExecutionReport executeBatchesSequentially(List batche } } - int batchCallsExecuted = 0; - int batchCallsFailed = 0; - for (int i = 0; i < items.size(); i++) { + int batchCallsExecuted = 0; + int batchCallsFailed = 0; + for (int i = 0; i < items.size(); i++) { + if (monitor.isCanceled()) { + report.setCanceled(true); + break; + } FunctionCallItem item = items.get(i); if (item == null || item.getCall() == null) { continue; } FunctionCall call = item.getCall(); - FunctionResult result = executeFunctionCall(assistantMessage.getId(), call); + FunctionResult result = executeFunctionCall(assistantMessage.getId(), call, monitor); item.setResult(result); batch.setResultForCall(i, result); callsExecuted++; batchCallsExecuted++; - if (isErrorResult(result)) { - callsFailed++; - batchCallsFailed++; - } + if (isErrorResult(result)) { + callsFailed++; + batchCallsFailed++; + } + if (monitor.isCanceled()) { + report.setCanceled(true); + break; } + } + + if (report.isCanceled()) { + batch.setExecutionComplete(false); + break; + } batch.setExecutionComplete(true); batchesExecuted++; @@ -331,7 +454,7 @@ private boolean isDebugToolBatchLoggingEnabled() { && activator.getPreferenceStore().getBoolean(PreferenceConstants.DEBUG_LOG_PROMPTS); } - private FunctionResult executeFunctionCall(UUID messageId, FunctionCall call) { + private FunctionResult executeFunctionCall(UUID messageId, FunctionCall call, IProgressMonitor monitor) { String functionName = call != null ? call.getFunctionName() : null; String argsJson = call != null ? call.getArgsJson() : "{}"; FunctionResult result = new FunctionResult(call != null ? call.getId() : null, @@ -351,10 +474,10 @@ private FunctionResult executeFunctionCall(UUID messageId, FunctionCall call) { handleApplyPatch(messageId, call, result, argsJson); break; case "perform_text_search": - handlePerformSearch(call, result, argsJson, false); + handlePerformSearch(call, result, argsJson, false, monitor); break; case "perform_regex_search": - handlePerformSearch(call, result, argsJson, true); + handlePerformSearch(call, result, argsJson, true, monitor); break; case "read_file_content": handleReadFileContent(call, result, argsJson); @@ -542,7 +665,7 @@ private void handleApplyPatch(UUID messageId, FunctionCall call, FunctionResult } private void handlePerformSearch(FunctionCall call, FunctionResult result, String functionArgsJson, - boolean isRegEx) { + boolean isRegEx, IProgressMonitor monitor) { try { // Parse the JSON arguments directly into a JsonObject JsonObject args = gson.fromJson(functionArgsJson, JsonObject.class); @@ -570,6 +693,9 @@ private void handlePerformSearch(FunctionCall call, FunctionResult result, Strin boolean isCaseSensitive = args.has("is_case_sensitive") ? args.get("is_case_sensitive").getAsBoolean() : false; boolean isWholeWord = args.has("is_whole_word") ? args.get("is_whole_word").getAsBoolean() : false; + boolean includeDerivedResources = args.has("include_derived_resources") + ? args.get("include_derived_resources").getAsBoolean() + : false; // Basic validation if (searchText == null) { @@ -585,7 +711,7 @@ private void handlePerformSearch(FunctionCall call, FunctionResult result, Strin } TextSearchTool.SearchExecutionResult searchExecResult = searchTool.performSearch(searchText, isRegEx, - isCaseSensitive, isWholeWord, fileNamePatterns); + isCaseSensitive, isWholeWord, fileNamePatterns, includeDerivedResources, monitor); call.addPrettyParam(searchParamName, searchText, isRegEx); // Mark as code if regex if (fileNamePatterns != null) { @@ -597,6 +723,7 @@ private void handlePerformSearch(FunctionCall call, FunctionResult result, Strin if (!isRegEx) { call.addPrettyParam("is_whole_word", String.valueOf(isWholeWord), false); } + call.addPrettyParam("include_derived_resources", String.valueOf(includeDerivedResources), false); JsonObject jsonResult = new JsonObject(); if (searchExecResult.isSuccess()) { diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/tools/TextSearchTool.java b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/tools/TextSearchTool.java index 67f60442..6893f196 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/tools/TextSearchTool.java +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/tools/TextSearchTool.java @@ -4,6 +4,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -11,6 +12,8 @@ import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.NullProgressMonitor; import org.eclipse.jdt.internal.ui.util.PatternConstructor; @@ -30,6 +33,16 @@ public class TextSearchTool { + private static final Set DEFAULT_EXCLUDED_ROOT_DIRS = Set.of( + "bin", + "target", + "build", + "out", + "dist", + "node_modules", + ".git", + ".gradle", ".svn"); + private final IResourceAccess resourceAccess; public TextSearchTool(IResourceAccess resourceAccess) { @@ -94,6 +107,26 @@ public List getResults() { public SearchExecutionResult performSearch(String searchText, boolean isRegEx, boolean isCaseSensitive, boolean isWholeWord, List fileNamePatterns) { + return performSearch(searchText, isRegEx, isCaseSensitive, isWholeWord, fileNamePatterns, + false, new NullProgressMonitor()); + } + + public SearchExecutionResult performSearch(String searchText, boolean isRegEx, boolean isCaseSensitive, + boolean isWholeWord, List fileNamePatterns, boolean includeDerivedResources) { + return performSearch(searchText, isRegEx, isCaseSensitive, isWholeWord, fileNamePatterns, + includeDerivedResources, new NullProgressMonitor()); + } + + public SearchExecutionResult performSearch(String searchText, boolean isRegEx, boolean isCaseSensitive, + boolean isWholeWord, List fileNamePatterns, IProgressMonitor monitor) { + return performSearch(searchText, isRegEx, isCaseSensitive, isWholeWord, fileNamePatterns, + false, monitor); + } + + public SearchExecutionResult performSearch(String searchText, boolean isRegEx, boolean isCaseSensitive, + boolean isWholeWord, List fileNamePatterns, boolean includeDerivedResources, + IProgressMonitor monitor) { + final IProgressMonitor searchMonitor = monitor != null ? monitor : new NullProgressMonitor(); TextSearchQueryProvider provider = TextSearchQueryProvider.getPreferred(); if (provider == null) { // Log.logError("No preferred TextSearchQueryProvider found."); // No need to @@ -117,7 +150,7 @@ public SearchExecutionResult performSearch(String searchText, boolean isRegEx, b String[] filePatternArray= filtered.toArray(new String[0]); Pattern compile = PatternConstructor.createPattern(filePatternArray, true, false); IResource[] resources = new IResource[] { ResourcesPlugin.getWorkspace().getRoot() }; - TextSearchScope scope = TextSearchScope.newSearchScope(resources, compile, true); + TextSearchScope scope = TextSearchScope.newSearchScope(resources, compile, includeDerivedResources); Map documentMap = new HashMap<>(); IPreferenceStore prefs = Activator.getDefault().getPreferenceStore(); @@ -126,6 +159,13 @@ public SearchExecutionResult performSearch(String searchText, boolean isRegEx, b TextSearchRequestor requestor = new TextSearchRequestor() { @Override public boolean acceptFile(IFile file) throws CoreException { + if (searchMonitor.isCanceled()) { + return false; + } + if (!includeDerivedResources + && (file.isDerived(IResource.CHECK_ANCESTORS) || isInDefaultExcludedRootDir(file))) { + return false; + } if (maxFiles >= 0 && items.size() >= maxFiles) { limitReached.set(true); return false; @@ -136,6 +176,9 @@ public boolean acceptFile(IFile file) throws CoreException { @Override public boolean acceptPatternMatch(TextSearchMatchAccess m) throws CoreException { + if (searchMonitor.isCanceled()) { + return false; + } // System.out.println("Found in: " + m.getFile().getFullPath() + // " offset=" + m.getMatchOffset() + " len=" + m.getMatchLength()); IFile file = m.getFile(); @@ -175,8 +218,10 @@ public boolean acceptPatternMatch(TextSearchMatchAccess m) TextSearchEngine engine = TextSearchEngine.create(); SearchExecutionResult result; try { - IStatus search = engine.search(scope, requestor, searchPattern, new NullProgressMonitor()); - if (search.getException() != null) { + IStatus search = engine.search(scope, requestor, searchPattern, searchMonitor); + if (searchMonitor.isCanceled()) { + result = new SearchExecutionResult(false, "Search canceled.", items); + } else if (search.getException() != null) { Activator.logError("Error searching", search.getException()); result = new SearchExecutionResult(false, "Search completed with errors: " + search.getException().getMessage(), items); } else { @@ -192,6 +237,15 @@ public boolean acceptPatternMatch(TextSearchMatchAccess m) return result; } + private boolean isInDefaultExcludedRootDir(IFile file) { + IPath projectRelativePath = file.getProjectRelativePath(); + if (projectRelativePath == null || projectRelativePath.segmentCount() < 2) { + return false; + } + + return DEFAULT_EXCLUDED_ROOT_DIRS.contains(projectRelativePath.segment(0)); + } + private List filter(List fileNamePatterns) { List res = fileNamePatterns.stream().map(p -> { int lastSlash = p.lastIndexOf("/"); diff --git a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/tools/tool_definitions.json b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/tools/tool_definitions.json index ba1ee2a9..88058992 100644 --- a/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/tools/tool_definitions.json +++ b/com.chabicht.code-intelligence/src/com/chabicht/code_intelligence/chat/tools/tool_definitions.json @@ -106,6 +106,10 @@ "is_whole_word": { "type": "boolean", "description": "Optional. True to match whole words only. Defaults to false if not provided." + }, + "include_derived_resources": { + "type": "boolean", + "description": "Optional. True to include Eclipse derived resources and default excluded root-level build/vendor directories such as bin, target, build, out, dist, node_modules, and .gradle. Defaults to false." } }, "required": [ @@ -134,6 +138,10 @@ "is_case_sensitive": { "type": "boolean", "description": "Optional. True for a case-sensitive search. Defaults to false if not provided." + }, + "include_derived_resources": { + "type": "boolean", + "description": "Optional. True to include Eclipse derived resources and default excluded root-level build/vendor directories such as bin, target, build, out, dist, node_modules, and .gradle. Defaults to false." } }, "required": [