diff --git a/build.gradle.kts b/build.gradle.kts index ec958421..c804ce65 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,6 +37,7 @@ plugins { id("org.jetbrains.intellij.platform") alias(libs.plugins.changelog) alias(libs.plugins.protobuf) + alias(libs.plugins.kotlin.serialization) } group = properties("pluginGroup").get() @@ -72,26 +73,37 @@ dependencies { implementation(project(":codegpt-telemetry")) implementation(project(":codegpt-treesitter")) + implementation(platform(libs.okhttp.bom)) + implementation(platform(libs.slf4j.bom)) implementation(platform(libs.jackson.bom)) - implementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8") - implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation(platform(libs.mcp.sdk.bom)) + + implementation(libs.jackson.datatype.jdk8) + implementation(libs.jackson.datatype.jsr310) + implementation(libs.jackson.module.kotlin) + implementation(libs.flexmark.all) { // vulnerable transitive dependency exclude(group = "org.jsoup", module = "jsoup") } - implementation(kotlin("stdlib")) - implementation(kotlin("reflect")) + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + implementation(libs.koog.agents) { + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-jdk8") + } implementation(libs.jsoup) implementation(libs.commons.text) implementation(libs.jtokkit) + implementation(libs.grpc.protobuf) implementation(libs.grpc.stub) implementation(libs.grpc.netty.shaded) implementation(libs.protobuf.runtime) - implementation(platform(libs.mcp.sdk.bom)) + implementation(libs.mcp.sdk) - testImplementation("junit:junit:4.13.2") + + testImplementation(libs.junit4) testImplementation(kotlin("test")) } diff --git a/buildSrc/src/main/kotlin/codegpt.java-conventions.gradle.kts b/buildSrc/src/main/kotlin/codegpt.java-conventions.gradle.kts index 999b6192..de892d7a 100644 --- a/buildSrc/src/main/kotlin/codegpt.java-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/codegpt.java-conventions.gradle.kts @@ -35,6 +35,7 @@ dependencies { } implementation(lib("llm.client")) + implementation(lib("kotlinx.serialization.json")) constraints { implementation(lib("okio")) { because("llm-client 0.7.0 uses okio 3.2.0: https://avd.aquasec.com/nvd/cve-2023-3635") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 554fa278..f6c7d785 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,8 +13,12 @@ jsoup = "1.21.2" jtokkit = "1.1.0" junit = "5.13.4" kotlin = "2.2.10" -llm-client = "0.8.56" +kotlinx-serialization = "1.6.3" +llm-client = "0.8.58" +koog = "0.6.0" +okhttp = "4.12.0" okio = "3.15.0" +slf4j = "2.0.17" tree-sitter = "0.24.5" grpc = "1.73.0" protobuf = "3.25.1" @@ -28,14 +32,24 @@ flexmark-all = { module = "com.vladsch.flexmark:flexmark-all", version.ref = "fl gradle-intellij-plugin = { module = "org.jetbrains.intellij.platform:intellij-platform-gradle-plugin", version.ref = "gradle-intellij-plugin-version" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } jackson-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson" } +jackson-datatype-jdk8 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jdk8", version.ref = "jackson" } +jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } +jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } mcp-sdk = { module = "io.modelcontextprotocol.sdk:mcp", version.ref = "mcp" } mcp-sdk-bom = { module = "io.modelcontextprotocol.sdk:mcp-bom", version.ref = "mcp" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } jtokkit = { module = "com.knuddels:jtokkit", version.ref = "jtokkit" } junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } +junit4 = { module = "junit:junit", version = "4.13.2" } +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +koog-agents = { module = "ai.koog:koog-agents", version.ref = "koog" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } llm-client = { module = "ee.carlrobert:llm-client", version.ref = "llm-client" } +okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +slf4j-bom = { module = "org.slf4j:slf4j-bom", version.ref = "slf4j" } tree-sitter = { module = "io.github.bonede:tree-sitter", version.ref = "tree-sitter" } grpc-protobuf = { module = "io.grpc:grpc-protobuf", version.ref = "grpc" } grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc" } @@ -46,4 +60,5 @@ protobuf-runtime = { module = "com.google.protobuf:protobuf-java", version.ref = [plugins] changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } protobuf = { id = "com.google.protobuf", version.ref = "protobuf-plugin" } diff --git a/src/main/java/ee/carlrobert/codegpt/completions/ToolwindowChatCompletionRequestHandler.java b/src/main/java/ee/carlrobert/codegpt/completions/ToolwindowChatCompletionRequestHandler.java index 92a84568..593e11fe 100644 --- a/src/main/java/ee/carlrobert/codegpt/completions/ToolwindowChatCompletionRequestHandler.java +++ b/src/main/java/ee/carlrobert/codegpt/completions/ToolwindowChatCompletionRequestHandler.java @@ -128,8 +128,8 @@ public class ToolwindowChatCompletionRequestHandler { } } catch (Throwable ex) { handleCallException(ex); - throw ex; } + return null; } private void handleCallException(Throwable ex) { diff --git a/src/main/java/ee/carlrobert/codegpt/settings/service/FeatureType.java b/src/main/java/ee/carlrobert/codegpt/settings/service/FeatureType.java index 6f7dff3f..7d1148a9 100644 --- a/src/main/java/ee/carlrobert/codegpt/settings/service/FeatureType.java +++ b/src/main/java/ee/carlrobert/codegpt/settings/service/FeatureType.java @@ -1,6 +1,7 @@ package ee.carlrobert.codegpt.settings.service; public enum FeatureType { + AGENT, CHAT, CODE_COMPLETION, AUTO_APPLY, diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java index 421f54fd..15a0e533 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java @@ -14,6 +14,7 @@ import com.intellij.ui.JBColor; import com.intellij.util.concurrency.AppExecutorUtil; import com.intellij.util.ui.JBUI; import ee.carlrobert.codegpt.CodeGPTKeys; +import ee.carlrobert.codegpt.CodeGPTBundle; import ee.carlrobert.codegpt.EncodingManager; import ee.carlrobert.codegpt.ReferencedFile; import ee.carlrobert.codegpt.actions.ActionType; @@ -38,6 +39,7 @@ import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction; import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureRepository; import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureState; import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody; +import ee.carlrobert.codegpt.toolwindow.chat.editor.header.LoadingPanel; import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatToolWindowScrollablePanel; import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensDetails; import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel; @@ -57,10 +59,10 @@ import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails; import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager; import ee.carlrobert.codegpt.util.EditorUtil; import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers; +import ee.carlrobert.llm.client.openai.completion.ErrorDetails; import git4idea.GitCommit; import java.awt.BorderLayout; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Objects; @@ -93,6 +95,8 @@ public class ChatToolWindowTabPanel implements Disposable { private final TagManager tagManager; private final JPanel mcpApprovalContainer; private @Nullable ToolwindowChatCompletionRequestHandler requestHandler; + private LoadingPanel inputLoadingPanel; + private final JPanel queuedMessageContainer; public ChatToolWindowTabPanel(@NotNull Project project, @NotNull Conversation conversation) { this.project = project; @@ -130,6 +134,11 @@ public class ChatToolWindowTabPanel implements Disposable { mcpApprovalContainer.setBorder(JBUI.Borders.empty()); mcpApprovalContainer.setOpaque(false); + queuedMessageContainer = new JPanel(); + queuedMessageContainer.setLayout(new BoxLayout(queuedMessageContainer, BoxLayout.Y_AXIS)); + queuedMessageContainer.setBorder(JBUI.Borders.empty()); + queuedMessageContainer.setOpaque(false); + rootPanel = createRootPanel(); if (conversation.getMessages().isEmpty()) { @@ -414,7 +423,7 @@ public class ChatToolWindowTabPanel implements Disposable { false, message.isWebSearchIncluded(), fileContextIncluded || message.getDocumentationDetails() != null, - true, + false, this)); return panel; } @@ -481,6 +490,7 @@ public class ChatToolWindowTabPanel implements Disposable { } userInputPanel.setSubmitEnabled(false); + userInputPanel.setStopEnabled(true); userMessagePanel.disableActions(List.of("RELOAD", "DELETE")); responseMessagePanel.disableActions(List.of("COPY")); @@ -493,6 +503,29 @@ public class ChatToolWindowTabPanel implements Disposable { totalTokensPanel, userInputPanel) { @Override + public void handleRequestOpen() { + super.handleRequestOpen(); + showInputLoading(CodeGPTBundle.get("toolwindow.chat.loading")); + } + + @Override + public void handleCompleted(String fullMessage, ChatCompletionParameters callParameters) { + try { + super.handleCompleted(fullMessage, callParameters); + } finally { + hideInputLoading(); + } + } + + @Override + public void handleError(ErrorDetails error, Throwable ex) { + try { + super.handleError(error, ex); + } finally { + hideInputLoading(); + } + } + @Override public void handleTokensExceededPolicyAccepted() { call(callParameters, responseMessagePanel, userMessagePanel); } @@ -500,6 +533,7 @@ public class ChatToolWindowTabPanel implements Disposable { this); requestHandler.setResponseMessagePanel(responseMessagePanel); + showInputLoading(CodeGPTBundle.get("toolwindow.chat.loading")); requestHandler.call(callParameters); } @@ -520,7 +554,7 @@ public class ChatToolWindowTabPanel implements Disposable { .filter(TagDetails::getSelected) .collect(Collectors.toList()); - var messageBuilder = new MessageBuilder(project, text).withInlays(appliedTags); + var messageBuilder = new MessageBuilder(project, text).withTags(appliedTags); List referencedFiles = getReferencedFiles(appliedTags); if (!referencedFiles.isEmpty()) { @@ -547,27 +581,15 @@ public class ChatToolWindowTabPanel implements Disposable { private Unit handleCancel() { if (requestHandler != null) { requestHandler.cancel(); + ApplicationManager.getApplication().invokeLater(() -> { + mcpApprovalContainer.removeAll(); + updateUserPromptPanel(); + hideInputLoading(); + userInputPanel.setSubmitEnabled(true); + CompletionProgressNotifier.update(project, false); + requestHandler = null; + }); } - ApplicationManager.getApplication().invokeLater(() -> { - mcpApprovalContainer.removeAll(); - updateUserPromptPanel(); - var lastComponent = toolWindowScrollablePanel.getLastComponent(); - if (lastComponent != null) { - Arrays.stream(lastComponent.getComponents()) - .filter(ResponseMessagePanel.class::isInstance) - .findFirst() - .ifPresent(panel -> { - ResponseMessagePanel responsePanel = (ResponseMessagePanel) panel; - var responseComponent = responsePanel.getResponseComponent(); - if (responseComponent != null) { - responseComponent.stopLoading(); - } - }); - } - userInputPanel.setSubmitEnabled(true); - CompletionProgressNotifier.update(project, false); - }); - requestHandler = null; return Unit.INSTANCE; } @@ -581,6 +603,16 @@ public class ChatToolWindowTabPanel implements Disposable { topContainer.setLayout(new BoxLayout(topContainer, BoxLayout.Y_AXIS)); topContainer.setOpaque(false); + inputLoadingPanel = new LoadingPanel(CodeGPTBundle.get("toolwindow.chat.loading"), null, null); + inputLoadingPanel.setAlignmentX(JComponent.LEFT_ALIGNMENT); + inputLoadingPanel.setVisible(false); + topContainer.add(inputLoadingPanel); + + if (queuedMessageContainer.getComponentCount() > 0) { + queuedMessageContainer.setAlignmentX(JComponent.LEFT_ALIGNMENT); + topContainer.add(queuedMessageContainer); + } + if (mcpApprovalContainer.getComponentCount() > 0) { mcpApprovalContainer.setAlignmentX(JComponent.LEFT_ALIGNMENT); topContainer.add(mcpApprovalContainer); @@ -596,6 +628,23 @@ public class ChatToolWindowTabPanel implements Disposable { return panel; } + private void showInputLoading(String text) { + if (inputLoadingPanel != null) { + inputLoadingPanel.setText(text); + inputLoadingPanel.setVisible(true); + inputLoadingPanel.revalidate(); + inputLoadingPanel.repaint(); + } + } + + private void hideInputLoading() { + if (inputLoadingPanel != null) { + inputLoadingPanel.setVisible(false); + inputLoadingPanel.revalidate(); + inputLoadingPanel.repaint(); + } + } + private JComponent getLandingView() { return new ChatToolWindowLandingPanel((action, locationOnScreen) -> { var editor = EditorUtil.getSelectedEditor(project); diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ToolWindowCompletionResponseEventListener.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ToolWindowCompletionResponseEventListener.java index 675c7b4a..c488b492 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ToolWindowCompletionResponseEventListener.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ToolWindowCompletionResponseEventListener.java @@ -35,7 +35,7 @@ abstract class ToolWindowCompletionResponseEventListener implements private final UserMessagePanel userMessagePanel; private ChatMessageResponseBody responseContainer; private final TotalTokensPanel totalTokensPanel; - private final UserInputPanel textArea; + private final UserInputPanel userInputPanel; private final Timer updateTimer = new Timer(UPDATE_INTERVAL_MS, e -> processBufferedMessages()); private final ConcurrentLinkedQueue messageBuffer = new ConcurrentLinkedQueue<>(); @@ -47,13 +47,13 @@ abstract class ToolWindowCompletionResponseEventListener implements UserMessagePanel userMessagePanel, ResponseMessagePanel responsePanel, TotalTokensPanel totalTokensPanel, - UserInputPanel textArea) { + UserInputPanel userInputPanel) { this.encodingManager = EncodingManager.getInstance(); this.project = project; this.userMessagePanel = userMessagePanel; this.responsePanel = responsePanel; this.totalTokensPanel = totalTokensPanel; - this.textArea = textArea; + this.userInputPanel = userInputPanel; } public abstract void handleTokensExceededPolicyAccepted(); @@ -146,7 +146,7 @@ abstract class ToolWindowCompletionResponseEventListener implements container.withResponse(fullMessage); } } - totalTokensPanel.updateUserPromptTokens(textArea.getText()); + totalTokensPanel.updateUserPromptTokens(userInputPanel.getText()); totalTokensPanel.updateConversationTokens(callParameters.getConversation()); } finally { stopStreaming(responseContainer); @@ -184,11 +184,11 @@ abstract class ToolWindowCompletionResponseEventListener implements private void stopStreaming(ChatMessageResponseBody responseContainer) { stopped = true; - textArea.setSubmitEnabled(true); + userInputPanel.setSubmitEnabled(true); + userInputPanel.setStopEnabled(false); userMessagePanel.enableAllActions(true); responsePanel.enableAllActions(true); if (responseContainer != null) { - responseContainer.stopLoading(); responseContainer.hideCaret(); responseContainer.finishThinking(); } diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java index 66d89460..855f8462 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java @@ -21,11 +21,9 @@ import com.intellij.openapi.ui.VerticalFlowLayout; import com.intellij.openapi.util.io.FileUtil; import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.ui.AnimatedIcon; import com.intellij.ui.PopupHandler; import com.intellij.ui.components.JBLabel; import com.intellij.util.ui.JBUI; -import com.intellij.util.ui.components.BorderLayoutPanel; import ee.carlrobert.codegpt.CodeGPTBundle; import ee.carlrobert.codegpt.Icons; import ee.carlrobert.codegpt.actions.ActionType; @@ -63,8 +61,8 @@ import java.awt.BorderLayout; import java.util.Objects; import java.util.stream.Stream; import javax.swing.DefaultListModel; +import javax.swing.JComponent; import javax.swing.JEditorPane; -import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JTextPane; import javax.swing.event.HyperlinkListener; @@ -83,7 +81,6 @@ public class ChatMessageResponseBody extends JPanel { private final DefaultListModel webpageListModel = new DefaultListModel<>(); private final WebpageList webpageList = new WebpageList(webpageListModel); private final ResponseBodyProgressPanel progressPanel = new ResponseBodyProgressPanel(); - private final JPanel loadingLabel = createLoadingPanel(); private final JPanel contentPanel = new JPanel(new VerticalFlowLayout(VerticalFlowLayout.TOP, 0, 4, true, false)); @@ -91,15 +88,6 @@ public class ChatMessageResponseBody extends JPanel { private JEditorPane currentlyProcessedTextPane; private JPanel webpageListPanel; - private JPanel createLoadingPanel() { - return new BorderLayoutPanel() - .addToLeft(new JBLabel( - CodeGPTBundle.get("toolwindow.chat.loading"), - new AnimatedIcon.Default(), - JLabel.LEFT)) - .withBorder(JBUI.Borders.empty(4, 0)); - } - public ChatMessageResponseBody(Project project, boolean compact, Disposable parentDisposable) { this(project, false, compact, false, false, false, parentDisposable); } @@ -122,10 +110,7 @@ public class ChatMessageResponseBody extends JPanel { setOpaque(false); contentPanel.setOpaque(false); - add(contentPanel, BorderLayout.NORTH); - - loadingLabel.setVisible(withLoading); - add(loadingLabel, BorderLayout.SOUTH); + add(contentPanel, BorderLayout.CENTER); if (ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT) == ServiceType.PROXYAI) { @@ -153,15 +138,11 @@ public class ChatMessageResponseBody extends JPanel { return this; } - public void stopLoading() { - loadingLabel.setVisible(false); - } - - public void addToolStatusPanel(JPanel panel) { + public void addToolStatusPanel(JComponent component) { currentlyProcessedTextPane = null; currentlyProcessedEditorPanel = null; streamOutputParser.clear(); - contentPanel.add(panel); + contentPanel.add(component); } public void updateMessage(String partialMessage) { @@ -199,6 +180,11 @@ public class ChatMessageResponseBody extends JPanel { }); } + public void displayCreditsExhausted() { + String message = "You’ve used all your ProxyAI credits. Get more credits to continue."; + displayErrorMessage(message, UIUtil::handleHyperlinkClicked); + } + public void displayError(String message) { displayErrorMessage(message, null); } @@ -244,7 +230,6 @@ public class ChatMessageResponseBody extends JPanel { public void clear() { contentPanel.removeAll(); streamOutputParser.clear(); - loadingLabel.setVisible(false); // Reset for the next incoming message prepareProcessingText(true); @@ -257,9 +242,6 @@ public class ChatMessageResponseBody extends JPanel { private void displayErrorMessage(String message, HyperlinkListener hyperlinkListener) { ApplicationManager.getApplication().invokeLater(() -> { - if (loadingLabel.isVisible()) { - loadingLabel.setVisible(false); - } if (webpageListPanel != null) { webpageListPanel.setVisible(false); } diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java index f9eedc0c..2220e600 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java @@ -4,12 +4,12 @@ import static ee.carlrobert.codegpt.CodeGPTKeys.CODEGPT_USER_DETAILS; import static ee.carlrobert.codegpt.settings.service.ServiceType.ANTHROPIC; import static ee.carlrobert.codegpt.settings.service.ServiceType.CUSTOM_OPENAI; import static ee.carlrobert.codegpt.settings.service.ServiceType.GOOGLE; +import static ee.carlrobert.codegpt.settings.service.ServiceType.INCEPTION; import static ee.carlrobert.codegpt.settings.service.ServiceType.LLAMA_CPP; import static ee.carlrobert.codegpt.settings.service.ServiceType.MISTRAL; import static ee.carlrobert.codegpt.settings.service.ServiceType.OLLAMA; import static ee.carlrobert.codegpt.settings.service.ServiceType.OPENAI; import static ee.carlrobert.codegpt.settings.service.ServiceType.PROXYAI; -import static ee.carlrobert.codegpt.settings.service.ServiceType.INCEPTION; import com.intellij.icons.AllIcons; import com.intellij.openapi.actionSystem.ActionUpdateThread; @@ -150,8 +150,15 @@ public class ModelComboBoxAction extends ComboBoxAction { } private AnAction[] getCodeGPTModelActions(Project project, Presentation presentation) { - var userDetails = CODEGPT_USER_DETAILS.get(project); var registry = ModelRegistry.getInstance(); + if (featureType == FeatureType.AGENT) { + return registry.getAllModelsForFeature(featureType).stream() + .filter(model -> model.getProvider() == PROXYAI) + .map(model -> createCodeGPTModelAction(model, presentation)) + .toArray(AnAction[]::new); + } + + var userDetails = CODEGPT_USER_DETAILS.get(project); return registry.getProxyAIChatModelsForPricingPlan( userDetails == null ? null : userDetails.getPricingPlan()).stream() .map(model -> createCodeGPTModelAction(model, presentation)) @@ -175,31 +182,28 @@ public class ModelComboBoxAction extends ComboBoxAction { if (availableProviders.contains(ANTHROPIC)) { var anthropicGroup = DefaultActionGroup.createPopupGroup(() -> "Anthropic"); anthropicGroup.getTemplatePresentation().setIcon(Icons.Anthropic); - anthropicGroup.add( - createAnthropicModelAction(ModelRegistry.CLAUDE_OPUS_4_20250514, presentation)); - anthropicGroup.add( - createAnthropicModelAction(ModelRegistry.CLAUDE_SONNET_4_20250514, presentation)); + ModelRegistry.getInstance().getAgentModels(ANTHROPIC).forEach(item -> + anthropicGroup.add(createModelAction( + ANTHROPIC, + item.getModel().getId(), + Icons.Anthropic, + presentation, + () -> ApplicationManager.getApplication().getService(ModelSettings.class) + .setModel(featureType, item.getModel().getId(), ANTHROPIC)))); actionGroup.add(anthropicGroup); } if (availableProviders.contains(OPENAI)) { var openaiGroup = DefaultActionGroup.createPopupGroup(() -> "OpenAI"); openaiGroup.getTemplatePresentation().setIcon(Icons.OpenAI); - List.of( - OpenAIChatCompletionModel.GPT_5, - OpenAIChatCompletionModel.GPT_5_MINI, - OpenAIChatCompletionModel.O_4_MINI, - OpenAIChatCompletionModel.O_3, - OpenAIChatCompletionModel.O_3_MINI, - OpenAIChatCompletionModel.O_1_PREVIEW, - OpenAIChatCompletionModel.O_1_MINI, - OpenAIChatCompletionModel.GPT_4_1, - OpenAIChatCompletionModel.GPT_4_1_MINI, - OpenAIChatCompletionModel.GPT_4_1_NANO, - OpenAIChatCompletionModel.GPT_4_O, - OpenAIChatCompletionModel.GPT_4_O_MINI, - OpenAIChatCompletionModel.GPT_4_0125_128k) - .forEach(model -> openaiGroup.add(createOpenAIModelAction(model, presentation))); + ModelRegistry.getInstance().getAgentModels(OPENAI).forEach(item -> + openaiGroup.add(createModelAction( + OPENAI, + item.getModel().getId(), + Icons.OpenAI, + presentation, + () -> ApplicationManager.getApplication().getService(ModelSettings.class) + .setModel(featureType, item.getModel().getId(), OPENAI)))); actionGroup.add(openaiGroup); } @@ -220,26 +224,32 @@ public class ModelComboBoxAction extends ComboBoxAction { if (availableProviders.contains(GOOGLE)) { var googleGroup = DefaultActionGroup.createPopupGroup(() -> "Google"); googleGroup.getTemplatePresentation().setIcon(Icons.Google); - List.of( - GoogleModel.GEMINI_2_5_PRO_PREVIEW, - GoogleModel.GEMINI_2_5_FLASH_PREVIEW, - GoogleModel.GEMINI_2_5_PRO, - GoogleModel.GEMINI_2_0_PRO_EXP, - GoogleModel.GEMINI_2_0_FLASH_THINKING_EXP, - GoogleModel.GEMINI_2_0_FLASH, - GoogleModel.GEMINI_1_5_PRO) - .forEach(model -> googleGroup.add(createGoogleModelAction(model, presentation))); + + ModelRegistry.getInstance().getAgentModels(GOOGLE).forEach(item -> + googleGroup.add(createModelAction( + GOOGLE, + item.getModel().getId(), + Icons.Google, + presentation, + () -> { + var application = ApplicationManager.getApplication(); + application.getService(ModelSettings.class) + .setModel(featureType, item.getModel().getId(), GOOGLE); + }))); actionGroup.add(googleGroup); } if (availableProviders.contains(MISTRAL)) { var mistralGroup = DefaultActionGroup.createPopupGroup(() -> "Mistral"); mistralGroup.getTemplatePresentation().setIcon(Icons.Mistral); - List.of( - ModelRegistry.DEVSTRAL_MEDIUM_2507, - ModelRegistry.MISTRAL_LARGE_2411, - ModelRegistry.CODESTRAL_LATEST) - .forEach(model -> mistralGroup.add(createMistralModelAction(model, presentation))); + ModelRegistry.getInstance().getAgentModels(MISTRAL).forEach(item -> + mistralGroup.add(createModelAction( + MISTRAL, + item.getModel().getId(), + Icons.Mistral, + presentation, + () -> ApplicationManager.getApplication().getService(ModelSettings.class) + .setModel(featureType, item.getModel().getId(), MISTRAL)))); actionGroup.add(mistralGroup); } @@ -310,7 +320,7 @@ public class ModelComboBoxAction extends ComboBoxAction { .filter(it -> modelCode != null && it.getModel().equals(modelCode)) .findFirst(); templatePresentation.setIcon( - proxyAIModel.map(ModelSelection::getIcon).orElse(Icons.CodeGPTModel)); + proxyAIModel.map(ModelSelection::getIcon).orElse(Icons.DefaultSmall)); templatePresentation.setText( proxyAIModel.map(ModelSelection::getDisplayName).orElse("Unknown")); break; @@ -321,7 +331,10 @@ public class ModelComboBoxAction extends ComboBoxAction { break; case CUSTOM_OPENAI: ModelRegistry.getInstance().getCustomOpenAIModels().stream() - .filter(it -> it.getModel().equals(modelCode)) + .filter(it -> { + var id = it.getId(); + return id != null && id.equals(modelCode); + }) .findFirst() .ifPresent(selection -> { templatePresentation.setIcon(Icons.OpenAI); @@ -354,7 +367,8 @@ public class ModelComboBoxAction extends ComboBoxAction { break; case INCEPTION: templatePresentation.setIcon(Icons.Inception); - var inceptionModelName = ModelRegistry.getInstance().getModelDisplayName(INCEPTION, modelCode); + var inceptionModelName = ModelRegistry.getInstance() + .getModelDisplayName(INCEPTION, modelCode); templatePresentation.setText(inceptionModelName); break; default: @@ -425,7 +439,7 @@ public class ModelComboBoxAction extends ComboBoxAction { var selected = model.getDisplayName().equals(comboBoxPresentation.getText()); var locked = shouldLockModel(model); return new CodeGPTModelsListPopupAction(model.getDisplayName(), model.getModel(), - model.getIcon() != null ? model.getIcon() : Icons.CodeGPTModel, + model.getIcon() != null ? model.getIcon() : Icons.DefaultSmall, model.getPricingPlan() != null ? model.getPricingPlan() : PricingPlan.ANONYMOUS, locked, selected, () -> { var application = ApplicationManager.getApplication(); @@ -438,6 +452,10 @@ public class ModelComboBoxAction extends ComboBoxAction { } private boolean shouldLockModel(ModelSelection model) { + if (featureType == FeatureType.AGENT) { + return false; + } + var userDetails = CODEGPT_USER_DETAILS.get(project); if (userDetails != null) { if (userDetails.getPricingPlan() == PricingPlan.INDIVIDUAL) { @@ -478,15 +496,20 @@ public class ModelComboBoxAction extends ComboBoxAction { } private AnAction createCustomOpenAIModelAction( - CustomServiceSettingsState model, + CustomServiceSettingsState state, Presentation comboBoxPresentation) { + var model = state.getChatCompletionSettings().getBody().get("model"); + var displayName = + state.getName() + + ((model instanceof String && !((String) model).isEmpty()) ? " (" + model + ")" : ""); + return createModelAction( CUSTOM_OPENAI, - model.getName(), + displayName, Icons.OpenAI, comboBoxPresentation, () -> ApplicationManager.getApplication().getService(ModelSettings.class) - .setModel(featureType, model.getId(), CUSTOM_OPENAI)); + .setModel(featureType, state.getId(), CUSTOM_OPENAI)); } private AnAction createGoogleModelAction(GoogleModel model, Presentation comboBoxPresentation) { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentEvents.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentEvents.kt new file mode 100644 index 00000000..e43b8bd8 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentEvents.kt @@ -0,0 +1,35 @@ +package ee.carlrobert.codegpt.agent + +import ai.koog.agents.core.feature.handler.agent.AgentCompletedContext +import ai.koog.prompt.executor.clients.LLMClientException +import ee.carlrobert.codegpt.agent.tools.AskUserQuestionTool +import ee.carlrobert.codegpt.conversations.message.TokenUsage +import ee.carlrobert.codegpt.settings.service.ServiceType +import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsEvent +import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalRequest + +interface AgentEvents { + fun onTextReceived(text: String) {} + fun onAgentCompleted(ctx: AgentCompletedContext) {} + fun onToolStarting(id: String, toolName: String, args: Any?) {} + fun onToolCompleted(id: String?, toolName: String, result: Any?) {} + fun onSubAgentToolStarting(parentId: String, toolName: String, args: Any?): String? = null + fun onSubAgentToolCompleted( + parentId: String, + childId: String?, + toolName: String, + result: Any? + ) { + } + + fun onQueuedMessagesResolved() + fun onTokenUsageAvailable(tokenUsage: Long) {} + fun onTokenUsageUpdated(tokenUsage: TokenUsage) {} + fun onCreditsAvailable(event: AgentCreditsEvent) {} + fun onClientException(provider: ServiceType, ex: LLMClientException) {} + + suspend fun approveToolCall(request: ToolApprovalRequest): Boolean = false + + suspend fun askUserQuestions(model: AskUserQuestionTool.AskUserQuestionsModel): Map = + emptyMap() +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentFactory.kt new file mode 100644 index 00000000..45afe73d --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentFactory.kt @@ -0,0 +1,456 @@ +package ee.carlrobert.codegpt.agent + +import ai.koog.agents.core.agent.AIAgent +import ai.koog.agents.core.agent.GraphAIAgent +import ai.koog.agents.core.agent.config.AIAgentConfig +import ai.koog.agents.core.dsl.builder.forwardTo +import ai.koog.agents.core.dsl.builder.strategy +import ai.koog.agents.core.dsl.extension.* +import ai.koog.agents.core.feature.handler.tool.ToolCallCompletedContext +import ai.koog.agents.core.feature.handler.tool.ToolCallStartingContext +import ai.koog.agents.core.tools.ToolRegistry +import ai.koog.agents.ext.tool.ExitTool +import ai.koog.agents.ext.tool.shell.ShellCommandConfirmation +import ai.koog.agents.features.eventHandler.feature.handleEvents +import ai.koog.prompt.dsl.prompt +import ai.koog.prompt.executor.clients.LLMClient +import ai.koog.prompt.executor.clients.anthropic.AnthropicLLMClient +import ai.koog.prompt.executor.clients.google.GoogleLLMClient +import ai.koog.prompt.executor.clients.mistralai.MistralAILLMClient +import ai.koog.prompt.executor.clients.openai.OpenAILLMClient +import ai.koog.prompt.executor.clients.retry.RetryConfig +import ai.koog.prompt.executor.clients.retry.RetryingLLMClient +import ai.koog.prompt.executor.llms.SingleLLMPromptExecutor +import ai.koog.prompt.executor.model.PromptExecutor +import ai.koog.prompt.executor.ollama.client.OllamaClient +import ai.koog.prompt.message.Message +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import ee.carlrobert.codegpt.agent.clients.CustomOpenAILLMClient +import ee.carlrobert.codegpt.agent.clients.ProxyAILLMClient +import ee.carlrobert.codegpt.agent.credits.extractCreditsSnapshot +import ee.carlrobert.codegpt.agent.tools.* +import ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey +import ee.carlrobert.codegpt.credentials.CredentialsStore.getCredential +import ee.carlrobert.codegpt.settings.ProxyAISettingsService +import ee.carlrobert.codegpt.settings.service.FeatureType +import ee.carlrobert.codegpt.settings.service.ModelSelectionService +import ee.carlrobert.codegpt.settings.service.ServiceType +import ee.carlrobert.codegpt.settings.service.custom.CustomServicesSettings +import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsEvent +import java.time.LocalDate +import kotlin.time.Duration.Companion.seconds + +object AgentFactory { + + fun createAgent( + agentType: AgentType, + provider: ServiceType, + project: Project, + sessionId: String, + approveToolCall: (suspend (name: String, details: String) -> Boolean)? = null, + onAgentToolCallStarting: ((eventContext: ToolCallStartingContext) -> Unit)? = null, + onAgentToolCallCompleted: ((eventContext: ToolCallCompletedContext) -> Unit)? = null, + extraBehavior: String? = null, + toolOverrides: Set? = null, + onCreditsAvailable: ((AgentCreditsEvent) -> Unit)? = null + ): AIAgent { + val installHandler = buildUsageAwareInstallHandler( + provider, + sessionId, + onAgentToolCallStarting, + onAgentToolCallCompleted, + onCreditsAvailable + ) + return when (agentType) { + AgentType.GENERAL_PURPOSE -> createGeneralPurposeAgent( + provider, + project, + sessionId, + approveToolCall, + installHandler, + extraBehavior, + toolOverrides + ) + + AgentType.EXPLORE -> createExploreAgent( + provider, + project, + sessionId, + approveToolCall, + installHandler, + extraBehavior, + toolOverrides + ) + } + } + + fun createManualAgent( + provider: ServiceType, + project: Project, + sessionId: String, + title: String, + behavior: String, + toolNames: Set, + approveToolCall: (suspend (name: String, details: String) -> Boolean)? = null, + onAgentToolCallStarting: ((eventContext: ToolCallStartingContext) -> Unit)? = null, + onAgentToolCallCompleted: ((eventContext: ToolCallCompletedContext) -> Unit)? = null, + onCreditsAvailable: ((AgentCreditsEvent) -> Unit)? = null, + ): AIAgent { + val installHandler = buildUsageAwareInstallHandler( + provider, + sessionId, + onAgentToolCallStarting, + onAgentToolCallCompleted, + onCreditsAvailable + ) + + val selected = SubagentTool.parse(toolNames) + val registry = createToolRegistry( + project, + sessionId, + approveToolCall, + selected, + approvalBashHandler(approveToolCall) + ) + + return AIAgent.Companion( + promptExecutor = createExecutor(provider), + strategy = singleRunWithParallelAbility(), + agentConfig = AIAgentConfig( + prompt = prompt("manual-agent") { + system( + """ + You are a user-defined subagent named "$title". + + ${getEnvironmentInfo(project)}${behaviorSection(behavior)} + """.trimIndent() + ) + }, + model = service().getAgentModel(), + maxAgentIterations = 100 + ), + toolRegistry = registry, + installFeatures = installHandler + ) + } + + fun createExecutor(provider: ServiceType): PromptExecutor { + return when (provider) { + ServiceType.OPENAI -> { + val apiKey = getCredential(CredentialKey.OpenaiApiKey) ?: "" + createRetryingExecutor(OpenAILLMClient(apiKey)) + } + + ServiceType.ANTHROPIC -> { + val apiKey = getCredential(CredentialKey.AnthropicApiKey) ?: "" + createRetryingExecutor(AnthropicLLMClient(apiKey)) + } + + ServiceType.GOOGLE -> { + val apiKey = getCredential(CredentialKey.GoogleApiKey) ?: "" + createRetryingExecutor(GoogleLLMClient(apiKey)) + } + + ServiceType.OLLAMA -> { + createRetryingExecutor(OllamaClient()) + } + + ServiceType.MISTRAL -> { + val apiKey = getCredential(CredentialKey.MistralApiKey) ?: "" + createRetryingExecutor(MistralAILLMClient(apiKey)) + } + + ServiceType.CUSTOM_OPENAI -> { + val state = service() + .customServiceStateForFeatureType(FeatureType.AGENT) + val serviceId = state.id + ?: throw IllegalArgumentException("No custom service configured") + val apiKey = getCredential(CredentialKey.CustomServiceApiKeyById(serviceId)) + ?: throw IllegalArgumentException("No API key found for custom service: $serviceId") + + createRetryingExecutor( + CustomOpenAILLMClient.fromSettingsState(apiKey, state.chatCompletionSettings) + ) + } + + ServiceType.PROXYAI -> { + val apiKey = getCredential(CredentialKey.CodeGptApiKey) ?: "" + createRetryingExecutor(ProxyAILLMClient(apiKey)) + } + + else -> throw UnsupportedOperationException("Provider not supported: $provider") + } + } + + private fun createRetryingExecutor(client: LLMClient): PromptExecutor { + val retryingClient = RetryingLLMClient( + client, + RetryConfig( + maxAttempts = 3, + initialDelay = 1.seconds, + maxDelay = 20.seconds, + backoffMultiplier = 2.0, + jitterFactor = 0.2 + ) + ) + return SingleLLMPromptExecutor(retryingClient) + } + + private fun createGeneralPurposeAgent( + provider: ServiceType, + project: Project, + sessionId: String, + approveToolCall: (suspend (name: String, details: String) -> Boolean)? = null, + installFeatures: GraphAIAgent.FeatureContext.() -> Unit = {}, + extraBehavior: String? = null, + toolOverrides: Set? = null + ): AIAgent { + + val selectedTools = toolOverrides ?: SubagentTool.entries.toSet() + return AIAgent.Companion( + promptExecutor = createExecutor(provider), + strategy = singleRunWithParallelAbility(), + agentConfig = AIAgentConfig( + prompt = prompt("general-agent") { + system( + """ + You are a general-purpose coding subagent operating inside a JetBrains IDE. You are invoked by a main agent to execute concrete steps. Work efficiently, call tools decisively, and keep messages concise and action-oriented. + + ${getEnvironmentInfo(project)}${behaviorSection(extraBehavior)} + + # Tone and Style + - Keep responses short, task-focused, and free of fluff. + - Use GitHub-flavored Markdown for structure when helpful. + + # Tool Usage Policy + - You may call multiple tools in a single turn. If calls are independent, run them in parallel. If calls depend on earlier results, run sequentially. + - Prefer specialized tools over bash: use Read for file content, Grep for search, Edit/Write for code changes. Use Bash only for true shell operations. + - Never use Bash to "echo" thoughts or communicate. All communication happens in your response text. + - Never guess parameters. If a required argument is unknown, first gather it via an appropriate tool. + - Respect approval gates: Edit/Write operations require confirmation hooks and may be denied. + - Use WebSearch for library/framework questions; use ResolveLibraryId/GetLibraryDocs for dependency docs. + - Use TodoWrite to outline and track subtasks when a request spans multiple steps. + + # Tool Routing Rules + - If the user asks about how to use a library, best practices, API semantics, configuration, or conventions: prefer ResolveLibraryId followed by GetLibraryDocs to gather authoritative guidance before proposing changes. + - Use IntelliJSearch/Grep to locate symbols and examples before editing files. + + # Collaboration as Subagent + - Assume a parent agent orchestrates overall strategy. Focus on execution quality and clear, minimal output. + - Do not create further planning layers; execute tasks and report concrete results or blockers. + + # Good Practices + - Be precise and cite evidence (paths/lines) for findings and changes. + - Batch independent reads/greps/web queries in parallel for speed. + - Validate at boundaries (user input, external APIs); trust internal code guarantees. + """.trimIndent() + ) + }, + model = service().getAgentModel(), + maxAgentIterations = 100 + ), + toolRegistry = createToolRegistry( + project, + sessionId, + approveToolCall, + selectedTools, + approvalBashHandler(approveToolCall) + ), + installFeatures = installFeatures + ) + } + + private fun createExploreAgent( + provider: ServiceType, + project: Project, + sessionId: String, + approveToolCall: (suspend (name: String, details: String) -> Boolean)? = null, + installFeatures: GraphAIAgent.FeatureContext.() -> Unit = {}, + extraBehavior: String? = null, + toolOverrides: Set? = null + ): AIAgent { + val selectedTools = toolOverrides ?: (SubagentTool.readOnly + SubagentTool.BASH).toSet() + return AIAgent.Companion( + promptExecutor = createExecutor(provider), + strategy = singleRunWithParallelAbility(), + agentConfig = AIAgentConfig( + prompt = prompt("explore-agent") { + system( + """ + You are an Explore subagent for codebase understanding. You are invoked by a main agent to gather context and answer questions by reading and searching the project. You do NOT modify files. + + ${getEnvironmentInfo(project)}${behaviorSection(extraBehavior)} + + # Tool Usage Policy (Read-only) + - Use Read to examine files; Grep to search patterns; WebSearch for external context; ResolveLibraryId/GetLibraryDocs for dependencies; TodoWrite to record findings. + - You may call multiple tools in parallel when independent (e.g., read multiple files, run several greps at once). + - Do not use Edit or Write. Avoid destructive Bash. Use Bash only for safe, read-only operations if strictly necessary. + - Never guess parameters; first gather precise paths/patterns. + + # Tool Routing Rules + - For library usage and best practices: ResolveLibraryId then GetLibraryDocs to retrieve relevant docs prior to summarizing advice. + - Use IntelliJSearch/Grep for code navigation and symbol discovery. + + # Exploration Workflow + - Initial scan: read key config/entry files in parallel; map structure. + - Deep dive: run targeted greps and reads in parallel for related components. + - Summarize: synthesize findings with concrete references; use TodoWrite to capture a brief outline of insights. + + # Good Practices + - Prefer breadth-first sampling before deep dives. + - Keep results actionable, with clear file paths and line references. + - Stop when the question is fully answered or sufficient context is gathered; otherwise continue exploration. + """.trimIndent() + ) + }, + model = service().getAgentModel(), + maxAgentIterations = 100 + ), + toolRegistry = createToolRegistry( + project, + sessionId, + approveToolCall = approveToolCall, + selected = selectedTools, + bashConfirmationHandler = { ShellCommandConfirmation.Approved } + ), + installFeatures = installFeatures + ) + } + + private fun buildUsageAwareInstallHandler( + provider: ServiceType, + sessionId: String, + onAgentToolCallStarting: ((eventContext: ToolCallStartingContext) -> Unit)?, + onAgentToolCallCompleted: ((eventContext: ToolCallCompletedContext) -> Unit)?, + onCreditsAvailable: ((AgentCreditsEvent) -> Unit)? + ): GraphAIAgent.FeatureContext.() -> Unit = { + handleEvents { + onToolCallStarting { ctx -> onAgentToolCallStarting?.invoke(ctx) } + onToolCallCompleted { ctx -> onAgentToolCallCompleted?.invoke(ctx) } + onNodeExecutionCompleted { ctx -> + val responses = (ctx.output as? List<*>)?.filterIsInstance() + ?: return@onNodeExecutionCompleted + if (responses.isEmpty()) return@onNodeExecutionCompleted + if (provider == ServiceType.PROXYAI) { + extractCredits(responses, sessionId)?.let { onCreditsAvailable?.invoke(it) } + } + } + } + } + + private fun extractCredits( + responses: List, + sessionId: String + ): AgentCreditsEvent? { + val credits = extractCreditsSnapshot(responses) ?: return null + return AgentCreditsEvent( + sessionId = sessionId, + remaining = credits.remaining, + monthlyRemaining = credits.monthlyRemaining, + consumed = credits.total + ) + } + + private fun singleRunWithParallelAbility() = strategy("subagent_single_run_sequential") { + val nodeCallLLM by nodeLLMRequestMultiple() + val nodeExecuteTool by nodeExecuteMultipleTools(parallelTools = true) + val nodeSendToolResult by nodeLLMSendMultipleToolResults() + + edge(nodeStart forwardTo nodeCallLLM) + edge(nodeCallLLM forwardTo nodeExecuteTool onMultipleToolCalls { true }) + edge( + nodeCallLLM forwardTo nodeFinish + onMultipleAssistantMessages { true } + transformed { it.joinToString("\n") { message -> message.content } } + ) + + edge(nodeExecuteTool forwardTo nodeSendToolResult) + + edge(nodeSendToolResult forwardTo nodeExecuteTool onMultipleToolCalls { true }) + + edge( + nodeSendToolResult forwardTo nodeFinish + onMultipleAssistantMessages { true } + transformed { it.joinToString("\n") { message -> message.content } } + ) + } + + private fun getEnvironmentInfo(project: Project): String { + val osName = System.getProperty("os.name") ?: "Unknown" + val osVersion = System.getProperty("os.version") ?: "Unknown" + val currentDate = LocalDate.now() + + return """ + + Working Directory: ${project.basePath} + Platform: $osName + OS Version: $osVersion + Current Date: $currentDate + + """.trimIndent() + } + + private fun createToolRegistry( + project: Project, + sessionId: String, + approveToolCall: (suspend (name: String, details: String) -> Boolean)?, + selected: Set, + bashConfirmationHandler: BashCommandConfirmationHandler + ): ToolRegistry { + return ToolRegistry.Companion { + if (SubagentTool.READ in selected) tool(ReadTool(project)) + if (SubagentTool.EDIT in selected) { + tool( + ConfirmingEditTool(EditTool(project)) { name, details -> + approveToolCall?.invoke(name, details) ?: true + } + ) + } + if (SubagentTool.WRITE in selected) { + tool( + ConfirmingWriteTool(WriteTool(project)) { name, details -> + approveToolCall?.invoke(name, details) ?: true + } + ) + } + if (SubagentTool.TODO_WRITE in selected) tool(TodoWriteTool(project, sessionId)) + if (SubagentTool.INTELLIJ_SEARCH in selected) tool(IntelliJSearchTool(project)) + if (SubagentTool.WEB_SEARCH in selected) tool(WebSearchTool()) + if (SubagentTool.BASH_OUTPUT in selected) tool(BashOutputTool(sessionId)) + if (SubagentTool.KILL_SHELL in selected) tool(KillShellTool()) + if (SubagentTool.RESOLVE_LIBRARY_ID in selected) tool(ResolveLibraryIdTool()) + if (SubagentTool.GET_LIBRARY_DOCS in selected) tool(GetLibraryDocsTool()) + if (SubagentTool.BASH in selected) { + tool( + BashTool( + project.basePath ?: "", + confirmationHandler = bashConfirmationHandler, + sessionId = sessionId, + settingsService = project.service() + ) + ) + } + tool(ExitTool) + } + } + + private fun approvalBashHandler( + approveToolCall: (suspend (name: String, details: String) -> Boolean)? + ): BashCommandConfirmationHandler { + return BashCommandConfirmationHandler { args -> + try { + val ok = approveToolCall?.invoke("Bash", args.command) != false + if (ok) ShellCommandConfirmation.Approved + else ShellCommandConfirmation.Denied("User rejected the command") + } catch (_: Exception) { + ShellCommandConfirmation.Approved + } + } + } + + private fun behaviorSection(behavior: String?): String { + val content = behavior?.trim().orEmpty() + return if (content.isEmpty()) "" else "\n# Subagent Behavior\n$content" + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentNotifier.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentNotifier.kt new file mode 100644 index 00000000..6530e053 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentNotifier.kt @@ -0,0 +1,12 @@ +package ee.carlrobert.codegpt.agent + +import com.intellij.util.messages.Topic + +interface AgentToolOutputNotifier { + fun toolOutput(toolId: String, text: String, isError: Boolean) + + companion object { + val AGENT_TOOL_OUTPUT_TOPIC = + Topic.create("agentToolOutput", AgentToolOutputNotifier::class.java) + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentService.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentService.kt new file mode 100644 index 00000000..acb82180 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentService.kt @@ -0,0 +1,122 @@ +package ee.carlrobert.codegpt.agent + +import ai.koog.agents.core.agent.AIAgent +import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider +import ai.koog.prompt.executor.clients.LLMClientException +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import ee.carlrobert.codegpt.conversations.message.TokenUsageTracker +import ee.carlrobert.codegpt.settings.service.FeatureType +import ee.carlrobert.codegpt.settings.service.ModelSelectionService +import ee.carlrobert.codegpt.toolwindow.agent.AgentSessionState +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.cancellation.CancellationException +import kotlin.io.path.Path + +@Service(Service.Level.PROJECT) +class AgentService(private val project: Project) { + + companion object { + private val logger = thisLogger() + } + + private val sessionJobs = ConcurrentHashMap() + private val pendingMessages = ConcurrentHashMap>() + private val sessionAgents = ConcurrentHashMap>() + private val sessionTokenTrackers = ConcurrentHashMap() + private val checkpointStorage = + JVMFilePersistenceStorageProvider(Path(project.basePath ?: "", ".proxyai")) + + private val _queuedMessageProcessed = MutableSharedFlow() + val queuedMessageProcessed = _queuedMessageProcessed.asSharedFlow() + + fun addToQueue(message: MessageWithContext, sessionId: String) { + pendingMessages.getOrPut(sessionId) { ArrayDeque() }.add(message) + } + + fun submitMessage(message: MessageWithContext, events: AgentEvents, sessionId: String) { + if (isSessionRunning(sessionId)) { + addToQueue(message, sessionId) + return + } + + val provider = service().getServiceForFeature(FeatureType.AGENT) + val previousCheckpoint = runBlocking { + val prevAgentId = + project.service().state.sessions + .firstOrNull { it.sessionId == sessionId }?.lastAgentId + if (prevAgentId == null) { + return@runBlocking null + } + + runCatching { + val checkpoints = checkpointStorage.getCheckpoints(prevAgentId) + val latestCheckpoint = checkpoints + .filter { it.nodePath != "tombstone" } + .maxByOrNull { it.createdAt } + latestCheckpoint + }.onFailure { ex -> + logger.error( + "Agent checkpoints: failed to load for session=$sessionId " + + "agentId=$prevAgentId error=${ex.message}", ex + ) + }.getOrNull() + } + + val agent = ProxyAIAgent.create( + project, + checkpointStorage, + previousCheckpoint, + provider, + events, + sessionId, + pendingMessages + ) + sessionAgents[sessionId] = agent + project.service().updateSession(sessionId, lastAgentId = agent.id) + sessionJobs[sessionId] = CoroutineScope(Dispatchers.IO).launch { + try { + agent.run(message) + } catch (ex: LLMClientException) { + events.onClientException(provider, ex) + } catch (_: CancellationException) { + return@launch + } catch (ex: Exception) { + logger.error(ex) + } finally { + sessionJobs.remove(sessionId) + } + } + } + + fun cancelCurrentRun(sessionId: String) { + sessionJobs[sessionId]?.cancel() + sessionJobs.remove(sessionId) + } + + fun isSessionRunning(sessionId: String): Boolean { + return sessionJobs[sessionId]?.isActive == true + } + + fun getPendingMessages(sessionId: String): List { + return pendingMessages[sessionId]?.toList() ?: emptyList() + } + + fun clearPendingMessages(sessionId: String) { + pendingMessages.remove(sessionId) + } + + fun getAgentForSession(sessionId: String): AIAgent? { + return sessionAgents[sessionId] + } + + fun getTokenTrackerForSession(sessionId: String): TokenUsageTracker { + return sessionTokenTrackers.getOrPut(sessionId) { TokenUsageTracker() } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentSystemPrompts.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentSystemPrompts.kt new file mode 100644 index 00000000..7ddb7f6d --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentSystemPrompts.kt @@ -0,0 +1,73 @@ +package ee.carlrobert.codegpt.agent + +import ee.carlrobert.codegpt.settings.models.ModelRegistry +import ee.carlrobert.codegpt.settings.models.ModelSelection +import ee.carlrobert.codegpt.settings.service.ServiceType +import ee.carlrobert.codegpt.util.file.FileUtil + +internal object AgentSystemPrompts { + private const val WORKING_DIRECTORY_TOKEN = "{{WORKING_DIRECTORY}}" + + private const val OPENAI_KEY = "openai" + private const val GEMINI_KEY = "gemini" + private const val ANTHROPIC_KEY = "anthropic" + + private val openAiPrompt: String by lazy { loadPrompt("/prompts/agent/openai.txt") } + private val geminiPrompt: String by lazy { loadPrompt("/prompts/agent/google.txt") } + private val anthropicPrompt: String by lazy { loadPrompt("/prompts/agent/anthropic.txt") } + + fun createSystemPrompt( + provider: ServiceType, + modelSelection: ModelSelection?, + projectPath: String? = null + ): String { + return when (provider) { + ServiceType.PROXYAI -> createProxyAiSystemPrompt(modelSelection, projectPath) + ServiceType.OPENAI -> createOpenAiSystemPrompt(projectPath) + ServiceType.GOOGLE -> createGeminiSystemPrompt(projectPath) + else -> createAnthropicSystemPrompt(projectPath) + } + } + + private fun createProxyAiSystemPrompt( + modelSelection: ModelSelection?, + projectPath: String? + ): String { + val modelId = modelSelection?.model + return if (modelId == ModelRegistry.PROXYAI_AUTO || modelId?.startsWith("claude") == true) { + createAnthropicSystemPrompt(projectPath) + } else { + createOpenAiSystemPrompt(projectPath) + } + } + + private fun createOpenAiSystemPrompt(projectPath: String? = null): String { + return renderPrompt(OPENAI_KEY, projectPath) + } + + private fun createGeminiSystemPrompt(projectPath: String? = null): String { + return renderPrompt(GEMINI_KEY, projectPath) + } + + private fun createAnthropicSystemPrompt(projectPath: String? = null): String { + return renderPrompt(ANTHROPIC_KEY, projectPath) + } + + private fun renderPrompt(key: String, projectPath: String?): String { + val workingDirectory = projectPath ?: "unknown" + return getPrompt(key).replace(WORKING_DIRECTORY_TOKEN, workingDirectory) + } + + private fun getPrompt(key: String): String { + return when (key) { + OPENAI_KEY -> openAiPrompt + GEMINI_KEY -> geminiPrompt + ANTHROPIC_KEY -> anthropicPrompt + else -> error("Unknown system prompt key: $key") + } + } + + private fun loadPrompt(path: String): String { + return FileUtil.getResourceContent(path).trimEnd() + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentType.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentType.kt new file mode 100644 index 00000000..c6ea9b30 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentType.kt @@ -0,0 +1,28 @@ +package ee.carlrobert.codegpt.agent + +/** + * Represents the different types of specialized agents that can be launched via the Task tool. + * Each agent type has a specific focus and optimized tool set for its intended use case. + */ +sealed class AgentType(val value: String, val description: String) { + + object GENERAL_PURPOSE : AgentType( + value = "general-purpose", + description = "General-purpose agent for complex research, code search, and multi-step tasks" + ) + + object EXPLORE : AgentType( + value = "explore", + description = "Fast agent specialized for exploring codebases and finding files/patterns" + ) + + companion object { + fun fromString(value: String): AgentType = when (value.lowercase().trim()) { + "general-purpose" -> GENERAL_PURPOSE + "explore" -> EXPLORE + else -> throw IllegalArgumentException( + "Unknown agent type: '$value'. Valid types are: general-purpose, explore, plan." + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/MessageWithContext.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/MessageWithContext.kt new file mode 100644 index 00000000..9a1834c8 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/MessageWithContext.kt @@ -0,0 +1,9 @@ +package ee.carlrobert.codegpt.agent + +import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails +import java.util.UUID + +class MessageWithContext(val text: String, val tags: List) { + + val id: UUID = UUID.randomUUID() +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/PromptEnhancer.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/PromptEnhancer.kt new file mode 100644 index 00000000..d9e78d53 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/PromptEnhancer.kt @@ -0,0 +1,212 @@ +package ee.carlrobert.codegpt.agent + +import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider +import ai.koog.prompt.dsl.prompt +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import ee.carlrobert.codegpt.settings.service.FeatureType +import ee.carlrobert.codegpt.settings.service.ModelSelectionService +import ee.carlrobert.codegpt.toolwindow.agent.AgentSessionState +import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory +import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails +import ee.carlrobert.codegpt.util.GitUtil +import ee.carlrobert.codegpt.util.ThinkingOutputParser +import ee.carlrobert.codegpt.util.file.FileUtil +import kotlinx.coroutines.runBlocking +import kotlin.io.path.Path +import ai.koog.prompt.message.Message as KoogMessage +import ai.koog.prompt.message.Message as PromptMessage +import ee.carlrobert.codegpt.conversations.message.Message as ChatMessage + +data class PromptEnhancement(val prompt: String, val includeGitChanges: Boolean) + +class PromptEnhancer(private val project: Project) { + + fun enhancePrompt( + prompt: String, + tags: List = emptyList(), + sessionId: String? = null + ): PromptEnhancement { + return runBlocking { enhancePromptInternal(prompt, tags, sessionId) } + } + + private suspend fun enhancePromptInternal( + prompt: String, + tags: List, + sessionId: String? + ): PromptEnhancement { + val trimmedPrompt = prompt.trim() + val gitDiff = GitUtil.getCurrentChanges(project)?.trim().orEmpty() + val tagsContext = buildTagsContext(tags) + val historyContext = buildHistoryContext(sessionId) + + val modelService = service() + val provider = modelService.getServiceForFeature(FeatureType.AGENT) + val model = modelService.getAgentModel() + val executor = AgentFactory.createExecutor(provider) + + val responseText = runCatching { + val p = prompt("prompt-enhancer") { + system(PromptEnhancerPrompts.DEFAULT_PROMPT) + user(buildUserPrompt(trimmedPrompt, gitDiff, tagsContext, historyContext)) + } + val responses = executor.execute(p, model, emptyList()) + responses.filterIsInstance() + .joinToString("\n") { it.content.trim() } + }.getOrDefault("") + + val enhanced = extractEnhancedPrompt(responseText.ifBlank { trimmedPrompt }) + + return PromptEnhancement( + prompt = enhanced, + includeGitChanges = gitDiff.isNotBlank() + ) + } + + private fun buildUserPrompt( + prompt: String, + gitDiff: String, + tagsContext: String, + historyContext: String + ): String { + return buildString { + if (historyContext.isNotBlank()) { + append("Previous conversation history:\n") + append(historyContext.trim()) + append("\n\n") + } + if (tagsContext.isNotBlank()) { + append("Selected tag context:\n") + append(tagsContext.trim()) + append("\n\n") + } + append("Original prompt:\n") + append(prompt) + if (gitDiff.isNotBlank()) { + append("\n\nCurrent git changes:\n```diff\n") + append(gitDiff) + append("\n```") + } + } + } + + private fun extractEnhancedPrompt(response: String): String { + val parser = ThinkingOutputParser() + val parsed = parser.processChunk(response) + val candidate = parsed.ifBlank { response }.trim() + return stripCodeFence(candidate) + } + + private fun stripCodeFence(text: String): String { + val trimmed = text.trim() + if (!trimmed.startsWith("```")) { + return trimmed + } + val closingIndex = trimmed.lastIndexOf("```") + if (closingIndex <= 2) { + return trimmed + } + val inner = trimmed.substring(3, closingIndex).trimStart() + val newlineIndex = inner.indexOf('\n') + if (newlineIndex == -1) { + return inner.trim() + } + val header = inner.substring(0, newlineIndex) + val body = inner.substring(newlineIndex + 1) + return if (header.all { it.isLetterOrDigit() || it == '-' || it == '_' }) { + body.trim() + } else { + inner.trim() + } + } + + private fun buildTagsContext(tags: List): String { + if (tags.isEmpty()) return "" + val contextBuilder = StringBuilder() + val tempMessage = ChatMessage("") + tags.forEach { tagDetails -> + val processor = TagProcessorFactory.getProcessor(project, tagDetails) + processor.process(tempMessage, contextBuilder) + } + appendTagMetadata(contextBuilder, tempMessage) + return contextBuilder.toString().trim() + } + + private fun appendTagMetadata(builder: StringBuilder, message: ChatMessage) { + val referencedFiles = message.referencedFilePaths.orEmpty() + if (referencedFiles.isNotEmpty()) { + builder.append("\nReferenced files:\n") + referencedFiles.forEach { path -> + builder.append("- ").append(path).append("\n") + } + } + message.documentationDetails?.let { details -> + builder.append("\nDocumentation:\n") + .append(details.name) + .append(" (") + .append(details.url) + .append(")\n") + } + message.personaName?.takeIf { it.isNotBlank() }?.let { persona -> + builder.append("\nPersona:\n").append(persona).append("\n") + } + message.imageFilePath?.takeIf { it.isNotBlank() }?.let { path -> + builder.append("\nImage:\n").append(path).append("\n") + } + } + + private suspend fun buildHistoryContext(sessionId: String?): String { + if (sessionId.isNullOrBlank()) return "" + val sessionState = project.service() + val agentId = sessionState.getLastAgentId(sessionId) ?: return "" + val storage = JVMFilePersistenceStorageProvider(Path(project.basePath ?: "", ".proxyai")) + val checkpoint = storage.getCheckpoints(agentId) + .filter { it.nodePath != "tombstone" } + .maxByOrNull { it.createdAt } + ?: return "" + return formatHistory(checkpoint.messageHistory) + } + + private fun formatHistory(messages: List): String { + val builder = StringBuilder() + messages.filterNot { it is PromptMessage.System }.forEach { msg -> + when (msg) { + is PromptMessage.User -> { + appendHistoryLine(builder, "User", msg.content) + } + + is PromptMessage.Assistant -> { + appendHistoryLine(builder, "Assistant", msg.content) + } + + is PromptMessage.Reasoning -> { + appendHistoryLine(builder, "Assistant", msg.content) + } + + is PromptMessage.Tool.Call -> { + appendHistoryLine(builder, "Tool Call (${msg.tool})", msg.content) + } + + is PromptMessage.Tool.Result -> { + appendHistoryLine(builder, "Tool Result (${msg.tool})", msg.content) + } + + else -> { + appendHistoryLine(builder, "Message", msg.toString()) + } + } + } + return builder.toString().trim() + } + + private fun appendHistoryLine(builder: StringBuilder, label: String, content: String) { + val trimmed = content.trim() + if (trimmed.isBlank()) return + builder.append(label).append(": ").append(trimmed).append("\n\n") + } +} + +private object PromptEnhancerPrompts { + private const val PROMPT_PATH = "/prompts/core/prompt-enhancer.txt" + val DEFAULT_PROMPT: String = FileUtil.getResourceContent(PROMPT_PATH) +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/ProxyAIAgent.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/ProxyAIAgent.kt new file mode 100644 index 00000000..5a9aae0e --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/ProxyAIAgent.kt @@ -0,0 +1,289 @@ +package ee.carlrobert.codegpt.agent + +import ai.koog.agents.core.agent.AIAgent +import ai.koog.agents.core.agent.config.AIAgentConfig +import ai.koog.agents.core.agent.context.RollbackStrategy +import ai.koog.agents.core.tools.ToolRegistry +import ai.koog.agents.ext.tool.ExitTool +import ai.koog.agents.ext.tool.shell.ShellCommandConfirmation +import ai.koog.agents.features.eventHandler.feature.handleEvents +import ai.koog.agents.features.tokenizer.feature.MessageTokenizer +import ai.koog.agents.features.tracing.feature.Tracing +import ai.koog.agents.features.tracing.writer.TraceFeatureMessageLogWriter +import ai.koog.agents.snapshot.feature.AgentCheckpointData +import ai.koog.agents.snapshot.feature.Persistence +import ai.koog.agents.snapshot.feature.persistence +import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider +import ai.koog.prompt.dsl.prompt +import ai.koog.prompt.message.Message +import ai.koog.prompt.streaming.StreamFrame +import ai.koog.prompt.tokenizer.Tokenizer +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import ee.carlrobert.codegpt.EncodingManager +import ee.carlrobert.codegpt.agent.strategy.SingleRunStrategyProvider +import ee.carlrobert.codegpt.agent.tools.* +import ee.carlrobert.codegpt.settings.ProxyAISettingsService +import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings +import ee.carlrobert.codegpt.settings.service.FeatureType +import ee.carlrobert.codegpt.settings.service.ModelSelectionService +import ee.carlrobert.codegpt.settings.service.ServiceType +import ee.carlrobert.codegpt.toolwindow.agent.AgentSessionState +import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.BashPayload +import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalRequest +import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalType +import io.github.oshai.kotlinlogging.KotlinLogging +import java.nio.file.Files +import java.nio.file.Paths +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +data class ToolError(val message: String) + +object ProxyAIAgent { + + private const val INSTRUCTION_FILE_NAME = "PROXYAI.md" + + private val logger = KotlinLogging.logger { } + + private fun searchForInstructions(projectPath: String?): String? { + if (projectPath == null) return null + val projectRoot = Paths.get(projectPath) + if (!Files.exists(projectRoot)) return null + + val proxyAiFile = projectRoot.resolve(INSTRUCTION_FILE_NAME) + if (Files.exists(proxyAiFile) && Files.isRegularFile(proxyAiFile)) { + try { + val content = Files.readString(proxyAiFile).trim() + if (content.isNotEmpty()) { + return content + } + } catch (_: Exception) { + } + } + + return null + } + + fun create( + project: Project, + checkpointStorage: JVMFilePersistenceStorageProvider, + previousCheckpoint: AgentCheckpointData?, + provider: ServiceType, + events: AgentEvents, + sessionId: String, + pendingMessages: ConcurrentHashMap>, + ): AIAgent { + val modelSelection = + service().getModelSelectionForFeature(FeatureType.AGENT) + val stream = + provider == ServiceType.ANTHROPIC || provider == ServiceType.OPENAI || provider == ServiceType.PROXYAI + val projectInstructions = searchForInstructions(project.basePath) + + val executor = AgentFactory.createExecutor(provider) + val pendingMessageQueue = pendingMessages.getOrPut(sessionId) { ArrayDeque() } + val toolRegistry = createToolRegistry(project, events, sessionId) + val agent = AIAgent( + promptExecutor = executor, + strategy = SingleRunStrategyProvider().build( + project, + executor, + projectInstructions, + previousCheckpoint, + pendingMessageQueue, + events, + sessionId, + provider, + stream + ), + agentConfig = AIAgentConfig( + prompt = prompt("proxyai-agent") { + system( + AgentSystemPrompts.createSystemPrompt( + provider, + modelSelection, + project.basePath + ) + ) + }, + model = service().getAgentModel(), + maxAgentIterations = 100 + ), + toolRegistry = toolRegistry, + ) { + if (ConfigurationSettings.getState().debugModeEnabled) { + install(Tracing) { + addMessageProcessor(TraceFeatureMessageLogWriter(logger)) + } + } + install(Persistence) { + storage = checkpointStorage + rollbackStrategy = RollbackStrategy.Default + } + install(MessageTokenizer) { + tokenizer = object : Tokenizer { + override fun countTokens(text: String): Int = + EncodingManager.getInstance().countTokens(text) + } + enableCaching = false + } + + handleEvents { + val toolCallToUiId: MutableMap = HashMap() + val anonymousToolIds: ArrayDeque = ArrayDeque() + + onLLMStreamingFrameReceived { ctx -> + if (!stream) return@onLLMStreamingFrameReceived + + val frame = ctx.streamFrame + if (frame is StreamFrame.Append) { + events.onTextReceived(frame.text) + } + } + + onNodeExecutionCompleted { ctx -> + val checkpoint = ctx.context.persistence().createCheckpoint( + agentContext = ctx.context, + nodePath = ctx.context.executionInfo.path(), + lastInput = ctx.input, + lastInputType = ctx.inputType, + checkpointId = ctx.context.runId, + version = 0L + ) + project.service().updateSession( + sessionId = sessionId, + lastAgentId = ctx.context.agentId, + checkpointId = checkpoint?.checkpointId + ) + + if (stream) return@onNodeExecutionCompleted + + (ctx.output as? List<*>)?.forEach { msg -> + (msg as? Message.Assistant)?.let { + events.onTextReceived(it.content) + } + (msg as? Message.Reasoning)?.let { + events.onTextReceived(it.content) + } + } + } + + onToolCallStarting { ctx -> + val id = ctx.toolCallId ?: UUID.randomUUID().toString() + if (ctx.toolCallId == null) { + anonymousToolIds.addLast(id) + } + val decodedArgs = + ToolCallPayloadDecoder.decodeArgs(toolRegistry, ctx.toolName, ctx.toolArgs) + events.onToolStarting(id, ctx.toolName, decodedArgs) + ToolRunContext.set(sessionId, id) + } + + onToolCallCompleted { ctx -> + val uiId = when { + ctx.toolCallId != null -> toolCallToUiId[ctx.toolCallId] ?: ctx.toolCallId + anonymousToolIds.isNotEmpty() -> anonymousToolIds.removeFirst() + else -> null + } + val decodedResult = ToolCallPayloadDecoder.decodeResult( + toolRegistry, + ctx.toolName, + ctx.toolResult + ) + events.onToolCompleted(uiId, ctx.toolName, decodedResult) + } + + onAgentCompleted { context -> + events.onAgentCompleted(context) + } + } + } + return agent + } + + private fun createToolRegistry( + project: Project, + events: AgentEvents, + sessionId: String + ): ToolRegistry { + return ToolRegistry { + tool(ReadTool(project)) + tool( + ConfirmingEditTool(EditTool(project)) { name, details -> + try { + events.approveToolCall( + ToolApprovalRequest( + if (name.equals("Edit", true) + ) ToolApprovalType.EDIT else ToolApprovalType.GENERIC, + "Allow $name?", + details + ) + ) + } catch (_: Exception) { + false + } + } + ) + tool( + ConfirmingWriteTool(WriteTool(project)) { name, details -> + try { + val type = if (name.equals("Write", true)) + ToolApprovalType.WRITE + else ToolApprovalType.GENERIC + + events.approveToolCall( + ToolApprovalRequest( + type, + "Allow $name?", + details + ) + ) + } catch (_: Exception) { + false + } + } + ) + tool(TodoWriteTool(project, sessionId)) + tool(AskUserQuestionTool(events)) + tool(ExitTool) + tool(IntelliJSearchTool(project)) + tool(WebSearchTool()) + tool(BashOutputTool(sessionId)) + tool(KillShellTool()) + tool(ResolveLibraryIdTool()) + tool(GetLibraryDocsTool()) + tool( + BashTool( + project.basePath ?: "", + confirmationHandler = { args -> + try { + val approved = events.approveToolCall( + ToolApprovalRequest( + ToolApprovalType.BASH, + "Run shell command?", + args.command, + BashPayload(args.command, args.description) + ) + ) + if (approved) ShellCommandConfirmation.Approved else ShellCommandConfirmation.Denied( + "User rejected the command" + ) + } catch (_: Exception) { + ShellCommandConfirmation.Approved + } + }, + sessionId = sessionId, + settingsService = project.service() + ) + ) + tool( + TaskTool( + project, + sessionId, + service().getServiceForFeature(FeatureType.AGENT), + events + ) + ) + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/SubagentTools.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/SubagentTools.kt new file mode 100644 index 00000000..6159d11b --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/SubagentTools.kt @@ -0,0 +1,39 @@ +package ee.carlrobert.codegpt.agent + +enum class SubagentTool(val id: String, val displayName: String, val isWrite: Boolean) { + READ("read", "Read", false), + TODO_WRITE("todowrite", "TodoWrite", false), + INTELLIJ_SEARCH("intellijsearch", "IntelliJSearch", false), + WEB_SEARCH("websearch", "WebSearch", false), + RESOLVE_LIBRARY_ID("resolvelibraryid", "ResolveLibraryId", false), + GET_LIBRARY_DOCS("getlibrarydocs", "GetLibraryDocs", false), + BASH_OUTPUT("bashoutput", "BashOutput", false), + KILL_SHELL("killshell", "KillShell", false), + EDIT("edit", "Edit", true), + WRITE("write", "Write", true), + BASH("bash", "Bash", true), + EXIT("exit", "Exit", false); + + companion object { + val readOnly: List = entries.filterNot { it.isWrite } + val write: List = entries.filter { it.isWrite } + + fun parse(values: Collection): Set { + return values.mapNotNull { fromString(it) }.toSet() + } + + fun toStoredValues(tools: Collection): List { + val selected = tools.toSet() + return entries.filter { it in selected }.map { it.id } + } + + fun fromString(value: String): SubagentTool? { + val key = normalize(value) + return entries.firstOrNull { normalize(it.id) == key || normalize(it.displayName) == key } + } + + private fun normalize(value: String): String { + return value.lowercase().filter { it.isLetterOrDigit() } + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/ToolCallPayloadDecoder.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/ToolCallPayloadDecoder.kt new file mode 100644 index 00000000..e01774b6 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/ToolCallPayloadDecoder.kt @@ -0,0 +1,86 @@ +package ee.carlrobert.codegpt.agent + +import ai.koog.agents.core.tools.ToolRegistry +import ee.carlrobert.codegpt.agent.tools.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +object ToolCallPayloadDecoder { + + private val fallbackJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + decodeEnumsCaseInsensitive = true + } + + fun decodeArgs(toolRegistry: ToolRegistry?, toolName: String, rawArgs: JsonObject): Any? { + val tool = toolRegistry?.getToolOrNull(toolName) + if (tool != null) { + return runCatching { tool.decodeArgs(rawArgs) }.getOrElse { rawArgs } + } + val serializer = argsSerializerFor(toolName) ?: return rawArgs + return decodeWith(serializer, rawArgs, rawArgs) + } + + fun decodeResult(toolRegistry: ToolRegistry?, toolName: String, rawResult: JsonElement?): Any? { + if (rawResult == null) return null + val tool = toolRegistry?.getToolOrNull(toolName) + if (tool != null) { + return runCatching { tool.decodeResult(rawResult) }.getOrElse { rawResult } + } + val serializer = resultSerializerFor(toolName) ?: return rawResult + return decodeWith(serializer, rawResult, rawResult) + } + + private fun argsSerializerFor(toolName: String): KSerializer? { + return when (toolName) { + "Read" -> ReadTool.Args.serializer() + "Write" -> WriteTool.Args.serializer() + "Edit" -> EditTool.Args.serializer() + "Bash" -> BashTool.Args.serializer() + "BashOutput" -> BashOutputTool.Args.serializer() + "KillShell" -> KillShellTool.Args.serializer() + "IntelliJSearch" -> IntelliJSearchTool.Args.serializer() + "WebSearch" -> WebSearchTool.Args.serializer() + "ResolveLibraryId" -> ResolveLibraryIdTool.Args.serializer() + "GetLibraryDocs" -> GetLibraryDocsTool.Args.serializer() + "Task" -> TaskTool.Args.serializer() + "AskUserQuestion" -> AskUserQuestionTool.Args.serializer() + "TodoWrite", "TodoWriteTool" -> TodoWriteTool.Args.serializer() + "Exit" -> Unit.serializer() + else -> null + } + } + + private fun resultSerializerFor(toolName: String): KSerializer? { + return when (toolName) { + "Read" -> ReadTool.Result.serializer() + "Write" -> WriteTool.Result.serializer() + "Edit" -> EditTool.Result.serializer() + "Bash" -> BashTool.Result.serializer() + "BashOutput" -> BashOutputTool.Result.serializer() + "KillShell" -> KillShellTool.Result.serializer() + "IntelliJSearch" -> IntelliJSearchTool.Result.serializer() + "WebSearch" -> WebSearchTool.Result.serializer() + "ResolveLibraryId" -> ResolveLibraryIdTool.Result.serializer() + "GetLibraryDocs" -> GetLibraryDocsTool.Result.serializer() + "Task" -> TaskTool.Result.serializer() + "AskUserQuestion" -> AskUserQuestionTool.Result.serializer() + "TodoWrite", "TodoWriteTool" -> String.serializer() + "Exit" -> Unit.serializer() + else -> null + } + } + + private fun decodeWith( + serializer: KSerializer, + raw: JsonElement, + fallback: Any? + ): Any? { + return runCatching { fallbackJson.decodeFromJsonElement(serializer, raw) }.getOrElse { fallback } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/ToolRunContext.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/ToolRunContext.kt new file mode 100644 index 00000000..f72b4180 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/ToolRunContext.kt @@ -0,0 +1,44 @@ +package ee.carlrobert.codegpt.agent + +import java.util.concurrent.ConcurrentHashMap + +data class ToolContext( + val sessionId: String, + val toolId: String, + val checkpointId: String? = null, + val parentToolId: String? = null, + val timestamp: Long = System.currentTimeMillis() +) + +object ToolRunContext { + private val sessionContexts = ConcurrentHashMap>() + + fun set(sessionId: String, toolId: String, checkpointId: String? = null) { + val currentContext = get(sessionId) + val context = ToolContext( + sessionId = sessionId, + toolId = toolId, + checkpointId = checkpointId, + parentToolId = currentContext?.toolId, + timestamp = System.currentTimeMillis() + ) + sessionContexts.computeIfAbsent(sessionId) { ThreadLocal() }.set(context) + } + + fun get(sessionId: String): ToolContext? { + return sessionContexts[sessionId]?.get() + } + + fun getToolId(sessionId: String): String? { + return sessionContexts[sessionId]?.get()?.toolId + } + + fun clear(sessionId: String) { + sessionContexts[sessionId]?.remove() + } + + fun cleanupSession(sessionId: String) { + sessionContexts.remove(sessionId) + } +} + diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIChatCompletion.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIChatCompletion.kt new file mode 100644 index 00000000..509638e4 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIChatCompletion.kt @@ -0,0 +1,162 @@ +package ee.carlrobert.codegpt.agent.clients + +import ai.koog.prompt.executor.clients.openai.base.models.* +import ai.koog.prompt.executor.clients.serialization.AdditionalPropertiesFlatteningSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +internal class CustomOpenAIChatCompletionRequest( + val messages: List = emptyList(), + val prompt: String? = null, + override val model: String? = null, + override val stream: Boolean? = null, + override val temperature: Double? = null, + val tools: List? = null, + val toolChoice: OpenAIToolChoice? = null, + override val topP: Double? = null, + override val topLogprobs: Int? = null, + val maxTokens: Int? = null, + val frequencyPenalty: Double? = null, + val presencePenalty: Double? = null, + val responseFormat: OpenAIResponseFormat? = null, + val stop: List? = null, + val logprobs: Boolean? = null, + val seed: Int? = null, + val topK: Int? = null, + val repetitionPenalty: Double? = null, + val logitBias: Map? = null, + val minP: Double? = null, + val topA: Double? = null, + val prediction: OpenAIStaticContent? = null, + val transforms: List? = null, + val models: List? = null, + val route: String? = null, + val user: String? = null, + val additionalProperties: Map? = null, +) : OpenAIBaseLLMRequest + +/** + * Chat completion choice + * + * @property finishReason The reason the model stopped generating tokens. + * This will be `stop` if the model hit a natural stop point or a provided stop sequence, + * `length` if the maximum number of tokens specified in the request was reached, + * `content_filter` if content was omitted due to a flag from our content filters, + * `tool_calls` if the model called a tool, or `function_call` (deprecated) if the model called a function. + * `error` if the model finishes the request with an error. + * @property nativeFinishReason The raw finish_reason string returned by the model + * @property message A chat completion message generated by the model. + * @property error An error response structure typically used for conveying error details to the clients. + * + * See (CompletionsResponse Format)[https://CustomOpenAI.ai/docs/api-reference/overview#completionsresponse-format] + */ +@Serializable +public class CustomOpenAIChoice( + public val finishReason: String? = null, + public val nativeFinishReason: String? = null, + public val message: OpenAIMessage, + public val error: ErrorResponse? = null +) + +/** + * Chat completion choice + * + * @property finishReason The reason the model stopped generating tokens. + * This will be `stop` if the model hit a natural stop point or a provided stop sequence, + * `length` if the maximum number of tokens specified in the request was reached, + * `content_filter` if content was omitted due to a flag from our content filters, + * `tool_calls` if the model called a tool, or `function_call` (deprecated) if the model called a function. + * `error` if the model finishes the request with an error. + * @property nativeFinishReason The raw finish_reason string returned by the model + * @property delta A chat completion delta generated by streamed model responses. + * @property error An error response structure typically used for conveying error details to the clients. + * + * See (CompletionsResponse Format)[https://CustomOpenAI.ai/docs/api-reference/overview#completionsresponse-format] + */ +@Serializable +public class CustomOpenAIStreamChoice( + public val finishReason: String? = null, + public val nativeFinishReason: String? = null, + public val delta: CustomOpenAIStreamDelta, + public val error: ErrorResponse? = null +) + +/** + * @property content The contents of the chunk message. + * @property role The role of the author of this message. + * @property toolCalls The tool calls requested by the model. + */ +@Serializable +public class CustomOpenAIStreamDelta( + public val content: String? = null, + public val role: String? = null, + public val toolCalls: List? = null +) + +@Serializable +public class CustomOpenAIToolCall( + public val id: String? = "", + public val function: CustomOpenAIFunction +) { + /** The type of the tool. Currently, only `function` is supported. */ + public val type: String = "function" +} + +@Serializable +public class CustomOpenAIFunction( + public val name: String? = "", + public val arguments: String? = "" +) + +/** + * Represents an error response structure typically used for conveying error details to the clients. + * + * @property code The numeric code representing the error. + * @property message A descriptive message providing details about the error. + * @property metadata Optional additional information about the error in the form of key-value pairs. + */ +@Serializable +public class ErrorResponse( + public val code: Int, + public val message: String, + public val metadata: Map? = null, +) + +/** + * CustomOpenAI Chat Completion Response + * See (CompletionsResponse Format)[https://CustomOpenAI.ai/docs/api-reference/overview#completionsresponse-format] + */ +@Serializable +public class CustomOpenAIChatCompletionResponse( + public val choices: List, + override val created: Long, + override val id: String, + override val model: String, + public val systemFingerprint: String? = null, + @SerialName("object") + public val objectType: String = "chat.completion", + public val usage: OpenAIUsage? = null, +) : OpenAIBaseLLMResponse + +/** + * CustomOpenAI Chat Completion Streaming Response + * See (CompletionsResponse Format)[https://CustomOpenAI.ai/docs/api-reference/overview#completionsresponse-format] + */ +@Serializable +public class CustomOpenAIChatCompletionStreamResponse( + public val choices: List, + override val created: Long, + override val id: String, + override val model: String, + public val systemFingerprint: String? = null, + @SerialName("object") + public val objectType: String = "chat.completion.chunk", + public val usage: OpenAIUsage? = null, +) : OpenAIBaseLLMStreamResponse + +internal object CustomOpenAIChatCompletionRequestSerializer : + AdditionalPropertiesFlatteningSerializer( + CustomOpenAIChatCompletionRequest.serializer() + ) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAILLMClient.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAILLMClient.kt new file mode 100644 index 00000000..392c915f --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAILLMClient.kt @@ -0,0 +1,228 @@ +package ee.carlrobert.codegpt.agent.clients + +import ai.koog.prompt.dsl.ModerationResult +import ai.koog.prompt.dsl.Prompt +import ai.koog.prompt.executor.clients.ConnectionTimeoutConfig +import ai.koog.prompt.executor.clients.LLMClient +import ai.koog.prompt.executor.clients.LLMClientException +import ai.koog.prompt.executor.clients.openai.base.AbstractOpenAILLMClient +import ai.koog.prompt.executor.clients.openai.base.OpenAIBaseSettings +import ai.koog.prompt.executor.clients.openai.base.OpenAICompatibleToolDescriptorSchemaGenerator +import ai.koog.prompt.executor.clients.openai.base.models.* +import ai.koog.prompt.llm.LLMProvider +import ai.koog.prompt.llm.LLModel +import ai.koog.prompt.message.LLMChoice +import ai.koog.prompt.message.Message +import ai.koog.prompt.message.ResponseMetaInfo +import ai.koog.prompt.params.LLMParams +import ai.koog.prompt.streaming.StreamFrameFlowBuilder +import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.client.* +import kotlinx.datetime.Clock +import java.net.URI +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Configuration settings for connecting to the CustomOpenAI API. + * + * @property baseUrl The base URL of the CustomOpenAI API. Default is "https://CustomOpenAI.ai/api/v1". + * @property timeoutConfig Configuration for connection timeouts including request, connection, and socket timeouts. + */ +public class CustomOpenAIClientSettings( + baseUrl: String, + chatCompletionsPath: String, + timeoutConfig: ConnectionTimeoutConfig = ConnectionTimeoutConfig( + requestTimeoutMillis = 120_000, + socketTimeoutMillis = 120_000 + ) +) : OpenAIBaseSettings(baseUrl, chatCompletionsPath, timeoutConfig) + +/** + * Implementation of [LLMClient] for CustomOpenAI API. + * CustomOpenAI is an API that routes requests to multiple LLM providers. + * + * @param apiKey The API key for the CustomOpenAI API + * @param settings The base URL and timeouts for the CustomOpenAI API, defaults to "https://CustomOpenAI.ai" and 900s + * @param clock Clock instance used for tracking response metadata timestamps. + */ +public class CustomOpenAILLMClient( + apiKey: String, + private val settings: CustomOpenAIClientSettings, + private val state: CustomServiceChatCompletionSettingsState, + baseClient: HttpClient = HttpClient(), + clock: Clock = Clock.System +) : AbstractOpenAILLMClient( + apiKey, + settings, + baseClient, + clock, + staticLogger, + OpenAICompatibleToolDescriptorSchemaGenerator() +) { + public data object CustomOpenAI : LLMProvider("custom-openai", "Custom OpenAI") + + + companion object { + private val staticLogger = KotlinLogging.logger { } + + init { + registerOpenAIJsonSchemaGenerators(CustomOpenAI) + } + + fun fromSettingsState( + apiKey: String, + state: CustomServiceChatCompletionSettingsState + ): CustomOpenAILLMClient { + val stateUrl = state.url ?: throw RuntimeException("Url not set") + val uri = URI.create(stateUrl) + val settings = CustomOpenAIClientSettings( + baseUrl = stateUrl.substring(0, stateUrl.lastIndexOf(uri.host) + (uri.host.length)), + chatCompletionsPath = uri.path, + ) + return CustomOpenAILLMClient(apiKey, settings, state) + } + } + + override fun llmProvider(): LLMProvider = CustomOpenAI + + override fun serializeProviderChatRequest( + messages: List, + model: LLModel, + tools: List?, + toolChoice: OpenAIToolChoice?, + params: LLMParams, + stream: Boolean + ): String { + val customParams = params.toCustomOpenAIParams(state) + val responseFormat = createResponseFormat(params.schema, model) + + val request = CustomOpenAIChatCompletionRequest( + messages = messages, + model = model.id, + stream = stream, + temperature = customParams.temperature, + tools = tools, + toolChoice = customParams.toolChoice?.toOpenAIToolChoice(), + topP = customParams.topP, + topLogprobs = customParams.topLogprobs, + maxTokens = customParams.maxTokens, + frequencyPenalty = customParams.frequencyPenalty, + presencePenalty = customParams.presencePenalty, + responseFormat = responseFormat, + stop = customParams.stop, + logprobs = customParams.logprobs, + topK = customParams.topK, + repetitionPenalty = customParams.repetitionPenalty, + minP = customParams.minP, + topA = customParams.topA, + prediction = customParams.speculation?.let { OpenAIStaticContent(Content.Text(it)) }, + transforms = customParams.transforms, + models = customParams.models, + route = customParams.route, + user = customParams.user, + additionalProperties = customParams.additionalProperties, + ) + + return json.encodeToString(CustomOpenAIChatCompletionRequestSerializer, request) + } + + override fun processProviderChatResponse(response: CustomOpenAIChatCompletionResponse): List { + require(response.choices.isNotEmpty()) { "Empty choices in response" } + return response.choices.map { + it.message.toResponses(it.finishReason, createMetaInfo(response.usage)) + } + } + + override fun decodeStreamingResponse(data: String): CustomOpenAIChatCompletionStreamResponse = + json.decodeFromString(data) + + override fun decodeResponse(data: String): CustomOpenAIChatCompletionResponse = + json.decodeFromString(data) + + override suspend fun StreamFrameFlowBuilder.processStreamingChunk(chunk: CustomOpenAIChatCompletionStreamResponse) { + chunk.choices.firstOrNull()?.let { choice -> + choice.delta.content?.let { emitAppend(it) } + choice.delta.toolCalls?.forEachIndexed { index, openAIToolCall -> + val id = openAIToolCall.id + val name = openAIToolCall.function.name + val arguments = openAIToolCall.function.arguments + upsertToolCall(index, id, name, arguments) + } + choice.finishReason?.let { emitEnd(it, createMetaInfo(chunk.usage)) } + } + } + + override suspend fun moderate(prompt: Prompt, model: LLModel): ModerationResult { + logger.warn { "Moderation is not supported by CustomOpenAI API" } + throw UnsupportedOperationException("Moderation is not supported by CustomOpenAI API.") + } + + @OptIn(ExperimentalEncodingApi::class) + private fun OpenAIMessage.toResponses( + finishReason: String?, + metaInfo: ResponseMetaInfo + ): List { + return when { + this is OpenAIMessage.Assistant && !this.toolCalls.isNullOrEmpty() -> { + val result = mutableListOf() + if (this.content != null) { + result.add( + Message.Assistant( + content = this.content!!.text(), + finishReason = finishReason, + metaInfo = metaInfo + ) + ) + } + + this.toolCalls!!.forEach { toolCall -> + result.add( + Message.Tool.Call( + id = toolCall.id, + tool = toolCall.function.name, + /* + If the tool has no arguments, OpenRouter puts an empty string in the arguments instead of an empty object + But we always expect arguments to be a JSON object. Fixing this. + */ + content = toolCall.function.arguments + .takeIf { it.isNotEmpty() } + ?: "{}", + metaInfo = metaInfo + ) + ) + } + return result + } + + this is OpenAIMessage.Assistant && this.reasoningContent != null && this.content != null -> listOf( + Message.Reasoning( + content = this.reasoningContent!!, + metaInfo = metaInfo + ), + Message.Assistant( + content = this.content!!.text(), + finishReason = finishReason, + metaInfo = metaInfo + ) + ) + + this.content != null -> listOf( + Message.Assistant( + content = this.content!!.text(), + finishReason = finishReason, + metaInfo = metaInfo + ) + ) + + else -> { + val exception = LLMClientException( + clientName, + "Unexpected response: no tool calls and no content" + ) + logger.error(exception) { exception.message } + throw exception + } + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIParams.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIParams.kt new file mode 100644 index 00000000..240207e9 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/CustomOpenAIParams.kt @@ -0,0 +1,235 @@ +package ee.carlrobert.codegpt.agent.clients + +import ai.koog.prompt.params.LLMParams +import ai.koog.prompt.params.LLMParams.Schema +import ai.koog.prompt.params.LLMParams.ToolChoice +import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState +import kotlinx.serialization.json.JsonElement + +internal fun LLMParams.toCustomOpenAIParams(state: CustomServiceChatCompletionSettingsState): CustomOpenAIParams { + if (this is CustomOpenAIParams) return this + return CustomOpenAIParams( + temperature = state.body["temperature"] as? Double ?: temperature, + maxTokens = state.body["maxTokens"] as? Int ?: maxTokens, + numberOfChoices = state.body["numberOfChoices"] as? Int ?: numberOfChoices, + speculation = state.body["speculation"] as? String ?: speculation, + schema = state.body["schema"] as? Schema ?: schema, + toolChoice = state.body["toolChoice"] as? ToolChoice ?: toolChoice, + user = state.body["user"] as? String ?: user, + additionalProperties = state.body["additionalProperties"] as? Map + ?: additionalProperties, + ) +} + +/** + * CustomOpenAI chat-completions parameters layered on top of [LLMParams]. + * + * @property temperature Sampling temperature in [0.0, 2.0]. Higher ⇒ more random; + * lower ⇒ more deterministic. Adjust this **or** [topP], not both. + * @property maxTokens Maximum number of tokens the model may generate for this response. + * @property numberOfChoices Number of completions to generate for the prompt (cost scales with N). + * @property speculation Provider-specific control for speculative decoding / draft acceleration. + * @property schema JSON Schema to constrain model output (validated when supported). + * @property toolChoice Controls if/which tool must be called (`none`/`auto`/`required`/specific). + * @property user stable end-user identifier + * @property includeThoughts Request inclusion of model “thoughts”/reasoning traces (model-dependent). + * @property thinkingBudget Soft cap on tokens spent on internal reasoning (reasoning models). + * @property additionalProperties Additional properties that can be used to store custom parameters. + * @property frequencyPenalty Number in [-2.0, 2.0]—penalizes frequent tokens to reduce repetition. + * @property presencePenalty Number in [-2.0, 2.0]—encourages introduction of new tokens/topics. + * @property logprobs Whether to include log-probabilities for output tokens. + * @property stop Stop sequences (0–4 items); generation halts before any of these. + * @property topLogprobs Number of top alternatives per position (0–20). Requires [logprobs] = true. + * @property topP Nucleus sampling in (0.0, 1.0]; use **instead of** [temperature]. + * @property topK Number of top tokens to consider when generating output (min 1). + * @property repetitionPenalty Number in (0.0, 2.0] — penalizes token repetition. + * @property minP Minimum cumulative probability for token inclusion in sampling. + * @property topA Temperature scaling based on marginal probability gain. + * @property transforms List of context transforms. + * Defines how context is transformed when it exceeds the model's token limit. + * Default is ["middle-out"] which truncates from the middle of the prompt. + * Use empty list [] for no transformations. + * @property models List of allowed models for this request. + * @property route Request routing identifier. + * @property provider Model provider preferences. + */ +public open class CustomOpenAIParams( + temperature: Double? = null, + maxTokens: Int? = null, + numberOfChoices: Int? = null, + speculation: String? = null, + schema: Schema? = null, + toolChoice: ToolChoice? = null, + user: String? = null, + additionalProperties: Map? = null, + public val frequencyPenalty: Double? = null, + public val presencePenalty: Double? = null, + public val logprobs: Boolean? = null, + public val stop: List? = null, + public val topLogprobs: Int? = null, + public val topP: Double? = null, + public val topK: Int? = null, + public val repetitionPenalty: Double? = null, + public val minP: Double? = null, + public val topA: Double? = null, + public val transforms: List? = null, + public val models: List? = null, + public val route: String? = null, +) : LLMParams( + temperature, maxTokens, numberOfChoices, + speculation, schema, toolChoice, + user, additionalProperties +) { + init { + require(topP == null || topP in 0.0..1.0) { + "topP must be in (0.0, 1.0], but was $topP" + } + if (topLogprobs != null) { + require(logprobs == true) { + "`topLogprobs` requires `logprobs=true`." + } + require(topLogprobs in 0..20) { + "topLogprobs must be in [0, 20], but was $topLogprobs" + } + } + require(frequencyPenalty == null || frequencyPenalty in -2.0..2.0) { + "frequencyPenalty must be in [-2.0, 2.0], but was $frequencyPenalty" + } + require(presencePenalty == null || presencePenalty in -2.0..2.0) { + "presencePenalty must be in [-2.0, 2.0], but was $presencePenalty" + } + require(repetitionPenalty == null || repetitionPenalty in 0.0..2.0) { + "repetitionPenalty must be in (0.0, 2.0], but was $repetitionPenalty" + } + require(topK == null || topK >= 1) { + "topK must be in [1, Infinity], but was $topK" + } + require(minP == null || minP in 0.0..1.0) { + "minP must be in [0.0, 1.0], but was $minP" + } + require(topA == null || topA in 0.0..1.0) { + "topA must be in [0.0, 1.0], but was $topA" + } + + // --- Stop sequences --- + if (stop != null) { + require(stop.isNotEmpty()) { "stop must not be empty when provided." } + require(stop.size <= 4) { "stop supports at most 4 sequences, but was ${stop.size}" } + require(stop.all { it.isNotBlank() }) { "stop sequences must not be blank." } + } + } + + /** + * Creates a copy of this instance with the ability to modify any of its properties. + */ + public fun copy( + temperature: Double? = this.temperature, + maxTokens: Int? = this.maxTokens, + numberOfChoices: Int? = this.numberOfChoices, + speculation: String? = this.speculation, + schema: Schema? = this.schema, + toolChoice: ToolChoice? = this.toolChoice, + user: String? = this.user, + additionalProperties: Map? = this.additionalProperties, + frequencyPenalty: Double? = this.frequencyPenalty, + presencePenalty: Double? = this.presencePenalty, + logprobs: Boolean? = this.logprobs, + stop: List? = this.stop, + topLogprobs: Int? = this.topLogprobs, + topP: Double? = this.topP, + topK: Int? = this.topK, + repetitionPenalty: Double? = this.repetitionPenalty, + minP: Double? = this.minP, + topA: Double? = this.topA, + transforms: List? = this.transforms, + models: List? = this.models, + route: String? = this.route, + ): CustomOpenAIParams = CustomOpenAIParams( + temperature = temperature, + maxTokens = maxTokens, + numberOfChoices = numberOfChoices, + speculation = speculation, + schema = schema, + toolChoice = toolChoice, + user = user, + additionalProperties = additionalProperties, + frequencyPenalty = frequencyPenalty, + presencePenalty = presencePenalty, + logprobs = logprobs, + stop = stop, + topLogprobs = topLogprobs, + topP = topP, + topK = topK, + repetitionPenalty = repetitionPenalty, + minP = minP, + topA = topA, + transforms = transforms, + models = models, + route = route, + ) + + override fun equals(other: Any?): Boolean = when { + this === other -> true + other !is CustomOpenAIParams -> false + else -> + temperature == other.temperature && + maxTokens == other.maxTokens && + numberOfChoices == other.numberOfChoices && + speculation == other.speculation && + schema == other.schema && + toolChoice == other.toolChoice && + user == other.user && + additionalProperties == other.additionalProperties && + frequencyPenalty == other.frequencyPenalty && + presencePenalty == other.presencePenalty && + logprobs == other.logprobs && + stop == other.stop && + topLogprobs == other.topLogprobs && + topP == other.topP && + topK == other.topK && + repetitionPenalty == other.repetitionPenalty && + minP == other.minP && + topA == other.topA && + transforms == other.transforms && + models == other.models && + route == other.route + } + + override fun hashCode(): Int = listOf( + temperature, maxTokens, numberOfChoices, + speculation, schema, toolChoice, + user, + additionalProperties, frequencyPenalty, presencePenalty, + logprobs, stop, topLogprobs, topP, + topK, repetitionPenalty, minP, + topA, transforms, models, route, + ).fold(0) { acc, element -> + 31 * acc + (element?.hashCode() ?: 0) + } + + override fun toString(): String = buildString { + append("CustomOpenAIParams(") + append("temperature=$temperature") + append(", maxTokens=$maxTokens") + append(", numberOfChoices=$numberOfChoices") + append(", speculation=$speculation") + append(", schema=$schema") + append(", toolChoice=$toolChoice") + append(", user=$user") + append(", additionalProperties=$additionalProperties") + append(", frequencyPenalty=$frequencyPenalty") + append(", presencePenalty=$presencePenalty") + append(", logprobs=$logprobs") + append(", stop=$stop") + append(", topLogprobs=$topLogprobs") + append(", topP=$topP") + append(", topK=$topK") + append(", repetitionPenalty=$repetitionPenalty") + append(", minP=$minP") + append(", topA=$topA") + append(", transforms=$transforms") + append(", models=$models") + append(", route=$route") + append(")") + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/ProxyAIChatCompletion.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/ProxyAIChatCompletion.kt new file mode 100644 index 00000000..d5e8c348 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/ProxyAIChatCompletion.kt @@ -0,0 +1,143 @@ +package ee.carlrobert.codegpt.agent.clients + +import ai.koog.prompt.executor.clients.openai.base.models.* +import ai.koog.prompt.executor.clients.serialization.AdditionalPropertiesFlatteningSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +internal class ProxyAIChatCompletionRequest( + val messages: List = emptyList(), + val prompt: String? = null, + override val model: String? = null, + override val stream: Boolean? = null, + override val temperature: Double? = null, + val tools: List? = null, + val toolChoice: OpenAIToolChoice? = null, + override val topP: Double? = null, + override val topLogprobs: Int? = null, + val maxTokens: Int? = null, + val frequencyPenalty: Double? = null, + val presencePenalty: Double? = null, + val responseFormat: OpenAIResponseFormat? = null, + val stop: List? = null, + val logprobs: Boolean? = null, + val seed: Int? = null, + val logitBias: Map? = null, + val user: String? = null, + val additionalProperties: Map? = null, +) : OpenAIBaseLLMRequest + +/** + * Chat completion choice. + */ +@Serializable +public class ProxyAIChoice( + public val finishReason: String? = null, + public val nativeFinishReason: String? = null, + public val message: OpenAIMessage, + public val error: ProxyAIErrorResponse? = null +) + +/** + * Chat completion streaming choice. + */ +@Serializable +public class ProxyAIStreamChoice( + public val finishReason: String? = null, + public val nativeFinishReason: String? = null, + public val delta: ProxyAIStreamDelta, + public val error: ProxyAIErrorResponse? = null +) + +/** + * @property content The contents of the chunk message. + * @property role The role of the author of this message. + * @property toolCalls The tool calls requested by the model. + */ +@Serializable +public class ProxyAIStreamDelta( + public val content: String? = null, + public val role: String? = null, + public val toolCalls: List? = null +) + +@Serializable +public class ProxyAIToolCall( + public val id: String? = "", + public val function: ProxyAIFunction +) { + /** The type of the tool. Currently, only `function` is supported. */ + public val type: String = "function" +} + +@Serializable +public class ProxyAIFunction( + public val name: String? = "", + public val arguments: String? = "" +) + +/** + * Represents an error response structure typically used for conveying error details to the clients. + */ +@Serializable +public class ProxyAIErrorResponse( + public val code: Int, + public val message: String, + public val metadata: Map? = null, +) + +@Serializable +public class ProxyAICredits( + public val prompt: Long? = null, + public val completion: Long? = null, + public val total: Long? = null, + public val remaining: Long? = null, +) + +@Serializable +public class ProxyAIUsage( + @SerialName("prompt_tokens") + public val promptTokens: Int? = null, + @SerialName("completion_tokens") + public val completionTokens: Int? = null, + @SerialName("total_tokens") + public val totalTokens: Int? = null, + public val credits: ProxyAICredits? = null, +) + +/** + * ProxyAI Chat Completion Response. + */ +@Serializable +public class ProxyAIChatCompletionResponse( + public val choices: List = emptyList(), + override val created: Long = 0L, + override val id: String = "", + override val model: String = "", + public val systemFingerprint: String? = null, + @SerialName("object") + public val objectType: String = "chat.completion", + public val usage: ProxyAIUsage? = null, +) : OpenAIBaseLLMResponse + +/** + * ProxyAI Chat Completion Streaming Response. + */ +@Serializable +public class ProxyAIChatCompletionStreamResponse( + public val choices: List = emptyList(), + override val created: Long = 0L, + override val id: String = "", + override val model: String = "", + public val systemFingerprint: String? = null, + @SerialName("object") + public val objectType: String = "chat.completion.chunk", + public val usage: ProxyAIUsage? = null, +) : OpenAIBaseLLMStreamResponse + +internal object ProxyAIChatCompletionRequestSerializer : + AdditionalPropertiesFlatteningSerializer( + ProxyAIChatCompletionRequest.serializer() + ) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/ProxyAILLMClient.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/ProxyAILLMClient.kt new file mode 100644 index 00000000..dda38c17 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/ProxyAILLMClient.kt @@ -0,0 +1,243 @@ +package ee.carlrobert.codegpt.agent.clients + +import ai.koog.prompt.dsl.ModerationResult +import ai.koog.prompt.dsl.Prompt +import ai.koog.prompt.executor.clients.ConnectionTimeoutConfig +import ai.koog.prompt.executor.clients.LLMClient +import ai.koog.prompt.executor.clients.LLMClientException +import ai.koog.prompt.executor.clients.openai.base.AbstractOpenAILLMClient +import ai.koog.prompt.executor.clients.openai.base.OpenAIBaseSettings +import ai.koog.prompt.executor.clients.openai.base.OpenAICompatibleToolDescriptorSchemaGenerator +import ai.koog.prompt.executor.clients.openai.base.models.* +import ai.koog.prompt.llm.LLMProvider +import ai.koog.prompt.llm.LLModel +import ai.koog.prompt.message.LLMChoice +import ai.koog.prompt.message.Message +import ai.koog.prompt.message.ResponseMetaInfo +import ai.koog.prompt.params.LLMParams +import ai.koog.prompt.streaming.StreamFrameFlowBuilder +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.client.* +import kotlinx.datetime.Clock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Configuration settings for connecting to the ProxyAI API. + */ +public class ProxyAIClientSettings( + baseUrl: String = DEFAULT_BASE_URL, + chatCompletionsPath: String = DEFAULT_CHAT_COMPLETIONS_PATH, + timeoutConfig: ConnectionTimeoutConfig = ConnectionTimeoutConfig( + requestTimeoutMillis = 120_000, + socketTimeoutMillis = 120_000 + ) +) : OpenAIBaseSettings(baseUrl, chatCompletionsPath, timeoutConfig) { + public companion object { + public const val DEFAULT_BASE_URL: String = "https://codegpt-api.carlrobert.ee" + public const val DEFAULT_CHAT_COMPLETIONS_PATH: String = "/v2/chat/completions" + } +} + +/** + * Implementation of [LLMClient] for ProxyAI API. + */ +public class ProxyAILLMClient( + apiKey: String, + private val settings: ProxyAIClientSettings = ProxyAIClientSettings(), + baseClient: HttpClient = HttpClient(), + clock: Clock = Clock.System +) : AbstractOpenAILLMClient( + apiKey, + settings, + baseClient, + clock, + staticLogger, + OpenAICompatibleToolDescriptorSchemaGenerator() +) { + public data object ProxyAI : LLMProvider("proxyai", "ProxyAI") + + companion object { + private val staticLogger = KotlinLogging.logger { } + + init { + registerOpenAIJsonSchemaGenerators(ProxyAI) + } + } + + override fun llmProvider(): LLMProvider = ProxyAI + + override fun serializeProviderChatRequest( + messages: List, + model: LLModel, + tools: List?, + toolChoice: OpenAIToolChoice?, + params: LLMParams, + stream: Boolean + ): String { + val proxyParams = params.toProxyAIParams() + val responseFormat = createResponseFormat(params.schema, model) + val request = ProxyAIChatCompletionRequest( + messages = messages, + model = model.id, + stream = stream, + temperature = proxyParams.temperature, + tools = tools, + toolChoice = proxyParams.toolChoice?.toOpenAIToolChoice(), + topP = proxyParams.topP, + topLogprobs = proxyParams.topLogprobs, + maxTokens = proxyParams.maxTokens, + frequencyPenalty = proxyParams.frequencyPenalty, + presencePenalty = proxyParams.presencePenalty, + responseFormat = responseFormat, + stop = proxyParams.stop, + logprobs = proxyParams.logprobs, + user = proxyParams.user, + additionalProperties = proxyParams.additionalProperties, + ) + + return json.encodeToString(ProxyAIChatCompletionRequestSerializer, request) + } + + override fun processProviderChatResponse(response: ProxyAIChatCompletionResponse): List { + if (response.choices.isEmpty()) { + val errorMsg = "Empty choices in response. Response may contain an error." + logger.error { errorMsg } + throw LLMClientException(clientName, errorMsg) + } + + // Check for errors in choices + response.choices.forEach { choice -> + choice.error?.let { error -> + val errorMsg = "ProxyAI API error (code: ${error.code}): ${error.message}" + logger.error { errorMsg } + throw LLMClientException(clientName, errorMsg) + } + } + + return response.choices.map { + it.message.toResponses(it.finishReason, createProxyMetaInfo(response.usage)) + } + } + + override fun decodeStreamingResponse(data: String): ProxyAIChatCompletionStreamResponse = + json.decodeFromString(data) + + override fun decodeResponse(data: String): ProxyAIChatCompletionResponse = + json.decodeFromString(data) + + override suspend fun StreamFrameFlowBuilder.processStreamingChunk(chunk: ProxyAIChatCompletionStreamResponse) { + chunk.choices.firstOrNull()?.let { choice -> + choice.delta.content?.let { emitAppend(it) } + choice.delta.toolCalls?.forEachIndexed { index, toolCall -> + val id = toolCall.id + val name = toolCall.function.name + val arguments = toolCall.function.arguments + upsertToolCall(index, id, name, arguments) + } + choice.finishReason?.let { emitEnd(it, createProxyMetaInfo(chunk.usage)) } + } + } + + override suspend fun moderate(prompt: Prompt, model: LLModel): ModerationResult { + logger.warn { "Moderation is not supported by ProxyAI API" } + throw UnsupportedOperationException("Moderation is not supported by ProxyAI API.") + } + + @OptIn(ExperimentalEncodingApi::class) + private fun OpenAIMessage.toResponses( + finishReason: String?, + metaInfo: ResponseMetaInfo + ): List { + return when { + this is OpenAIMessage.Assistant && !this.toolCalls.isNullOrEmpty() -> { + val result = mutableListOf() + if (this.content != null) { + result.add( + Message.Assistant( + content = this.content!!.text(), + finishReason = finishReason, + metaInfo = metaInfo + ) + ) + } + + this.toolCalls!!.forEach { toolCall -> + result.add( + Message.Tool.Call( + id = toolCall.id, + tool = toolCall.function.name, + content = toolCall.function.arguments + .takeIf { it.isNotEmpty() } + ?: "{}", + metaInfo = metaInfo + ) + ) + } + return result + } + + this is OpenAIMessage.Assistant && this.reasoningContent != null && this.content != null -> listOf( + Message.Reasoning( + content = this.reasoningContent!!, + metaInfo = metaInfo + ), + Message.Assistant( + content = this.content!!.text(), + finishReason = finishReason, + metaInfo = metaInfo + ) + ) + + this.content != null -> listOf( + Message.Assistant( + content = this.content!!.text(), + finishReason = finishReason, + metaInfo = metaInfo + ) + ) + + else -> { + val exception = LLMClientException( + clientName, + "Unexpected response: no tool calls and no content" + ) + logger.error(exception) { exception.message } + throw exception + } + } + } + + private fun createProxyMetaInfo(usage: ProxyAIUsage?): ResponseMetaInfo { + val metadata = usage?.credits?.toMetadata() + return ResponseMetaInfo.create( + clock = clock, + totalTokensCount = usage?.totalTokens, + inputTokensCount = usage?.promptTokens, + outputTokensCount = usage?.completionTokens, + metadata = metadata + ) + } + + private fun ProxyAICredits.toMetadata(): JsonObject? { + val creditsObject = buildJsonObject { + putIfNotNull("prompt", prompt) + putIfNotNull("completion", completion) + putIfNotNull("total", total) + putIfNotNull("remaining", remaining) + } + if (creditsObject.isEmpty()) return null + return buildJsonObject { + put("credits", creditsObject) + } + } + + private fun JsonObjectBuilder.putIfNotNull(key: String, value: Long?) { + if (value != null) { + put(key, JsonPrimitive(value)) + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/ProxyAIParams.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/ProxyAIParams.kt new file mode 100644 index 00000000..fede140f --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/ProxyAIParams.kt @@ -0,0 +1,151 @@ +package ee.carlrobert.codegpt.agent.clients + +import ai.koog.prompt.params.LLMParams +import ai.koog.prompt.params.LLMParams.Schema +import ai.koog.prompt.params.LLMParams.ToolChoice +import kotlinx.serialization.json.JsonElement + +internal fun LLMParams.toProxyAIParams(): ProxyAIParams { + if (this is ProxyAIParams) return this + return ProxyAIParams( + temperature = temperature, + maxTokens = maxTokens, + numberOfChoices = numberOfChoices, + speculation = speculation, + schema = schema, + toolChoice = toolChoice, + user = user, + additionalProperties = additionalProperties, + ) +} + +/** + * ProxyAI chat-completions parameters layered on top of [LLMParams]. + */ +public open class ProxyAIParams( + temperature: Double? = null, + maxTokens: Int? = null, + numberOfChoices: Int? = null, + speculation: String? = null, + schema: Schema? = null, + toolChoice: ToolChoice? = null, + user: String? = null, + additionalProperties: Map? = null, + public val frequencyPenalty: Double? = null, + public val presencePenalty: Double? = null, + public val logprobs: Boolean? = null, + public val stop: List? = null, + public val topLogprobs: Int? = null, + public val topP: Double? = null, +) : LLMParams( + temperature, maxTokens, numberOfChoices, + speculation, schema, toolChoice, + user, additionalProperties +) { + init { + require(topP == null || topP in 0.0..1.0) { + "topP must be in (0.0, 1.0], but was $topP" + } + if (topLogprobs != null) { + require(logprobs == true) { + "`topLogprobs` requires `logprobs=true`." + } + require(topLogprobs in 0..20) { + "topLogprobs must be in [0, 20], but was $topLogprobs" + } + } + require(frequencyPenalty == null || frequencyPenalty in -2.0..2.0) { + "frequencyPenalty must be in [-2.0, 2.0], but was $frequencyPenalty" + } + require(presencePenalty == null || presencePenalty in -2.0..2.0) { + "presencePenalty must be in [-2.0, 2.0], but was $presencePenalty" + } + + if (stop != null) { + require(stop.isNotEmpty()) { "stop must not be empty when provided." } + require(stop.size <= 4) { "stop supports at most 4 sequences, but was ${stop.size}" } + require(stop.all { it.isNotBlank() }) { "stop sequences must not be blank." } + } + } + + public fun copy( + temperature: Double? = this.temperature, + maxTokens: Int? = this.maxTokens, + numberOfChoices: Int? = this.numberOfChoices, + speculation: String? = this.speculation, + schema: Schema? = this.schema, + toolChoice: ToolChoice? = this.toolChoice, + user: String? = this.user, + additionalProperties: Map? = this.additionalProperties, + frequencyPenalty: Double? = this.frequencyPenalty, + presencePenalty: Double? = this.presencePenalty, + logprobs: Boolean? = this.logprobs, + stop: List? = this.stop, + topLogprobs: Int? = this.topLogprobs, + topP: Double? = this.topP, + ): ProxyAIParams = ProxyAIParams( + temperature = temperature, + maxTokens = maxTokens, + numberOfChoices = numberOfChoices, + speculation = speculation, + schema = schema, + toolChoice = toolChoice, + user = user, + additionalProperties = additionalProperties, + frequencyPenalty = frequencyPenalty, + presencePenalty = presencePenalty, + logprobs = logprobs, + stop = stop, + topLogprobs = topLogprobs, + topP = topP, + ) + + override fun equals(other: Any?): Boolean = when { + this === other -> true + other !is ProxyAIParams -> false + else -> + temperature == other.temperature && + maxTokens == other.maxTokens && + numberOfChoices == other.numberOfChoices && + speculation == other.speculation && + schema == other.schema && + toolChoice == other.toolChoice && + user == other.user && + additionalProperties == other.additionalProperties && + frequencyPenalty == other.frequencyPenalty && + presencePenalty == other.presencePenalty && + logprobs == other.logprobs && + stop == other.stop && + topLogprobs == other.topLogprobs && + topP == other.topP + } + + override fun hashCode(): Int = listOf( + temperature, maxTokens, numberOfChoices, + speculation, schema, toolChoice, + user, additionalProperties, + frequencyPenalty, presencePenalty, + logprobs, stop, topLogprobs, topP, + ).fold(0) { acc, element -> + 31 * acc + (element?.hashCode() ?: 0) + } + + override fun toString(): String = buildString { + append("ProxyAIParams(") + append("temperature=$temperature") + append(", maxTokens=$maxTokens") + append(", numberOfChoices=$numberOfChoices") + append(", speculation=$speculation") + append(", schema=$schema") + append(", toolChoice=$toolChoice") + append(", user=$user") + append(", additionalProperties=$additionalProperties") + append(", frequencyPenalty=$frequencyPenalty") + append(", presencePenalty=$presencePenalty") + append(", logprobs=$logprobs") + append(", stop=$stop") + append(", topLogprobs=$topLogprobs") + append(", topP=$topP") + append(")") + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/credits/CreditsExtractor.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/credits/CreditsExtractor.kt new file mode 100644 index 00000000..23982898 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/credits/CreditsExtractor.kt @@ -0,0 +1,27 @@ +package ee.carlrobert.codegpt.agent.credits + +import ai.koog.prompt.message.Message +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +data class CreditsSnapshot( + val remaining: Long, + val monthlyRemaining: Long, + val total: Long, +) + +fun extractCreditsSnapshot(responses: List): CreditsSnapshot? { + val creditsObject = responses.asReversed().firstNotNullOfOrNull { response -> + response.metaInfo.metadata?.get("credits")?.jsonObject + } ?: return null + val remaining = creditsObject.longOrNull("remaining") ?: return null + val monthlyRemaining = creditsObject.longOrNull("monthly_remaining") ?: remaining + val total = creditsObject.longOrNull("total") ?: 0 + return CreditsSnapshot(remaining, monthlyRemaining, total) +} + +private fun JsonObject.longOrNull(key: String): Long? { + return this[key]?.jsonPrimitive?.longOrNull +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/strategy/SingleRunStrategyProvider.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/strategy/SingleRunStrategyProvider.kt new file mode 100644 index 00000000..9da89500 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/strategy/SingleRunStrategyProvider.kt @@ -0,0 +1,301 @@ +package ee.carlrobert.codegpt.agent.strategy + +import ai.koog.agents.core.agent.config.AIAgentConfig +import ai.koog.agents.core.agent.entity.AIAgentGraphStrategy +import ai.koog.agents.core.agent.session.AIAgentLLMWriteSession +import ai.koog.agents.core.dsl.builder.AIAgentEdgeBuilderIntermediate +import ai.koog.agents.core.dsl.builder.EdgeTransformationDslMarker +import ai.koog.agents.core.dsl.builder.forwardTo +import ai.koog.agents.core.dsl.builder.strategy +import ai.koog.agents.core.dsl.extension.nodeExecuteMultipleTools +import ai.koog.agents.core.dsl.extension.onIsInstance +import ai.koog.agents.core.dsl.extension.onMultipleToolCalls +import ai.koog.agents.core.environment.ReceivedToolResult +import ai.koog.agents.core.environment.result +import ai.koog.agents.features.tokenizer.feature.tokenizer +import ai.koog.agents.snapshot.feature.AgentCheckpointData +import ai.koog.prompt.dsl.Prompt +import ai.koog.prompt.executor.model.PromptExecutor +import ai.koog.prompt.message.Message +import ai.koog.prompt.message.RequestMetaInfo +import ai.koog.prompt.streaming.StreamFrame +import ai.koog.prompt.streaming.toMessageResponses +import ai.koog.prompt.tokenizer.PromptTokenizer +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import ee.carlrobert.codegpt.ReferencedFile +import ee.carlrobert.codegpt.agent.AgentEvents +import ee.carlrobert.codegpt.agent.MessageWithContext +import ee.carlrobert.codegpt.agent.credits.extractCreditsSnapshot +import ee.carlrobert.codegpt.completions.CompletionRequestUtil +import ee.carlrobert.codegpt.settings.service.ServiceType +import ee.carlrobert.codegpt.toolwindow.agent.AgentCreditsEvent +import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory +import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import java.util.* +import ee.carlrobert.codegpt.conversations.message.Message as ChatMessage + +internal interface AgentRunStrategyProvider { + fun build( + project: Project, + executor: PromptExecutor, + projectInstructions: String?, + previousCheckpoint: AgentCheckpointData?, + pendingMessageQueue: ArrayDeque, + events: AgentEvents, + sessionId: String, + provider: ServiceType, + stream: Boolean + ): AIAgentGraphStrategy +} + +internal class SingleRunStrategyProvider : AgentRunStrategyProvider { + override fun build( + project: Project, + executor: PromptExecutor, + projectInstructions: String?, + previousCheckpoint: AgentCheckpointData?, + pendingMessageQueue: ArrayDeque, + events: AgentEvents, + sessionId: String, + provider: ServiceType, + stream: Boolean + ): AIAgentGraphStrategy = strategy("single_run") { + val nodeCallLLM by node> { message -> + llm.writeSession { + if (previousCheckpoint == null) { + projectInstructions?.let { + appendPrompt { + message( + Message.User( + it, + RequestMetaInfo(clock.now(), buildJsonObject { + put("cache_control", buildJsonObject { + put("type", JsonPrimitive("ephemeral")) + }) + }) + ) + ) + } + + appendPrompt { + user(it) + } + } + } else { + val filteredMessageHistory = previousCheckpoint.messageHistory + .filter { it !is Message.System } + .toMutableList() + .apply { + if (lastOrNull() is Message.Tool.Call) { + removeLast() + } + } + + for (previousMessage in filteredMessageHistory) { + if (previousMessage !is Message.System) { + appendPrompt { message(previousMessage) } + } + } + } + + if (message.tags.isNotEmpty()) { + val context = buildTagContext(project, message.tags) + if (context.isNotBlank()) { + appendPrompt { user(context) } + } + } + + appendPrompt { user(message.text) } + + requestAndPublish( + executor, + config, + tokenizer(), + events, + sessionId, + provider, + stream + ) + } + } + + val nodeExecuteTool by nodeExecuteMultipleTools(parallelTools = true) + + val nodeSendToolResult by node, List> { results -> + llm.writeSession { + appendPrompt { + results.forEach { tool { result(it) } } + } + + val messages = prompt.messages + val toolCallMessages = messages.count { it is Message.Tool.Call } + val todoWriteToolUsed = messages.any { msg -> + msg is Message.Tool.Call && msg.tool == "TodoWrite" + } + + if (toolCallMessages >= 2 && !todoWriteToolUsed) { + appendPrompt { + user("It seems that you haven't created a todo list yet. If the task on hand requires multiple steps then create a todo list to track your changes.") + } + } + + if (pendingMessageQueue.isNotEmpty()) { + pendingMessageQueue.forEach { + appendPrompt { user(it.text) } + } + events.onQueuedMessagesResolved() + pendingMessageQueue.clear() + } + + requestAndPublish( + executor, + config, + tokenizer(), + events, + sessionId, + provider, + stream + ) + } + } + + edge(nodeStart forwardTo nodeCallLLM) + edge(nodeCallLLM forwardTo nodeExecuteTool onMultipleToolCalls { true }) + edge(nodeCallLLM forwardTo nodeFinish onSingleAssistantResponse { true }) + edge(nodeExecuteTool forwardTo nodeSendToolResult) + edge(nodeSendToolResult forwardTo nodeFinish onSingleAssistantResponse { true }) + edge(nodeSendToolResult forwardTo nodeExecuteTool onMultipleToolCalls { true }) + } +} + +private suspend fun AIAgentLLMWriteSession.requestResponses( + stream: Boolean, + executor: PromptExecutor, + config: AIAgentConfig, + appendResponse: (Message.Response) -> Unit +): List { + val responses = if (stream) { + val streamFrames = mutableListOf() + val frames = requestLLMStreaming() + frames.collect { streamFrames.add(it) } + streamFrames.toMessageResponses() + } else { + val preparedPrompt = config.missingToolsConversionStrategy.convertPrompt(prompt, tools) + executor.execute(preparedPrompt, model, tools) + } + val appendableResponses = appendableResponses(responses) + appendableResponses.forEach(appendResponse) + return if (stream) responses else appendableResponses +} + +private suspend fun AIAgentLLMWriteSession.requestAndPublish( + executor: PromptExecutor, + config: AIAgentConfig, + tokenizer: PromptTokenizer, + events: AgentEvents, + sessionId: String, + provider: ServiceType, + stream: Boolean +): List { + val response = requestResponses(stream, executor, config) { appendPrompt { message(it) } } + publishUsageAndCredits(prompt, tokenizer, events, sessionId, provider, response) + return response +} + +private fun appendableResponses(responses: List): List { + return responses + .sortedBy { if (it is Message.Assistant) 0 else 1 } + .filter { it !is Message.Reasoning } +} + +private fun publishUsageAndCredits( + prompt: Prompt, + tokenizer: PromptTokenizer, + events: AgentEvents, + sessionId: String, + provider: ServiceType, + responses: List, +) { + events.onTokenUsageAvailable(promptTokenCount(prompt, tokenizer).toLong()) + publishCreditsIfAvailable(sessionId, provider, responses, events) +} + +private fun promptTokenCount(prompt: Prompt, tokenizer: PromptTokenizer): Int { + return if (prompt.latestTokenUsage == 0) { + tokenizer.tokenCountFor(prompt) + } else { + prompt.latestTokenUsage + } +} + +private fun buildTagContext(project: Project, tags: List): String { + val contextBuilder = StringBuilder() + val tempMessage = ChatMessage("") + tags.forEach { tagDetails -> + val processor = TagProcessorFactory.getProcessor(project, tagDetails) + processor.process(tempMessage, contextBuilder) + } + + val fileContext = buildReferencedFilesContext(tempMessage.referencedFilePaths.orEmpty()) + val tagText = contextBuilder.toString().trim() + return buildString { + var hasContent = false + if (tagText.isNotBlank()) { + append(tagText) + hasContent = true + } + if (fileContext.isNotBlank()) { + if (hasContent) { + append("\n\n") + } + append(fileContext) + } + }.trim() +} + +private fun buildReferencedFilesContext(referencedFilePaths: List): String { + if (referencedFilePaths.isEmpty()) return "" + val vfs = LocalFileSystem.getInstance() + val referencedFiles = referencedFilePaths + .asSequence() + .mapNotNull { path -> vfs.findFileByPath(path) } + .distinctBy { it.path } + .map { ReferencedFile.from(it) } + .toList() + if (referencedFiles.isEmpty()) return "" + return CompletionRequestUtil.buildContextBlock(referencedFiles, null) +} + +private fun publishCreditsIfAvailable( + sessionId: String, + provider: ServiceType, + responses: List, + events: AgentEvents, +) { + if (provider != ServiceType.PROXYAI) return + val credits = extractCreditsSnapshot(responses) ?: return + val event = AgentCreditsEvent( + sessionId = sessionId, + remaining = credits.remaining, + monthlyRemaining = credits.monthlyRemaining, + consumed = credits.total + ) + events.onCreditsAvailable(event) +} + +@EdgeTransformationDslMarker +private infix fun AIAgentEdgeBuilderIntermediate, OutgoingInput>.onSingleAssistantResponse( + block: suspend (Message.Response) -> Boolean +): AIAgentEdgeBuilderIntermediate { + return onIsInstance(List::class) + .transformed { response -> + response.filter { item -> item is Message.Response && item !is Message.Reasoning } + .filterIsInstance() + } + .onCondition { it.size == 1 && it[0] is Message.Assistant } + .onCondition { messages -> block(messages[0]) } + .transformed { it[0].content } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/AskUserQuestionTool.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/AskUserQuestionTool.kt new file mode 100644 index 00000000..f990aa03 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/AskUserQuestionTool.kt @@ -0,0 +1,107 @@ +package ee.carlrobert.codegpt.agent.tools + +import ai.koog.agents.core.tools.Tool +import ai.koog.agents.core.tools.annotations.LLMDescription +import ee.carlrobert.codegpt.agent.AgentEvents +import kotlinx.serialization.Serializable + +class AskUserQuestionTool( + private val events: AgentEvents +) : Tool( + argsSerializer = Args.serializer(), + resultSerializer = Result.serializer(), + name = "AskUserQuestion", + description = """ + Use this tool when you need to ask the user questions during execution. This allows you to: + 1. Gather user preferences or requirements + 2. Clarify ambiguous instructions + 3. Get decisions on implementation choices as you work + 4. Offer choices to the user about what direction to take. + + Usage notes: + - Users will always be able to select "Other" to provide custom text input + - Use multiSelect: true to allow multiple answers to be selected for a question + - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label + """.trimIndent() +) { + + @Serializable + data class Option( + @property:LLMDescription("The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.") + val label: String, + @property:LLMDescription("Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.") + val description: String + ) + + @Serializable + data class Question( + @property:LLMDescription("The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: \"Which library should we use for date formatting?\" If multiSelect is true, phrase it accordingly, e.g. \"Which features do you want to enable?\"") + val question: String, + @property:LLMDescription("Very short label displayed as a chip/tag (max 12 chars). Examples: \"Auth method\", \"Library\", \"Approach\".") + val header: String, + @property:LLMDescription("The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.") + val options: List