diff --git a/CHANGELOG.md b/CHANGELOG.md index 6186a69a..0c2726dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.7.5-241.1] - 2026-03-12 + +### Added + +- Mercury 2 model via the Inception provider +- Markdown table rendering in chat responses [#1186](https://github.com/carlrobertoh/ProxyAI/issues/1186) +- `workingDirectory` override support for the Bash tool +- Support for closing the first tool window tab [#865](https://github.com/carlrobertoh/ProxyAI/issues/865) + +### Changed + +- Refreshed the built-in model catalog and provider integrations + +### Fixed + +- Bash tool execution on Linux [#1189](https://github.com/carlrobertoh/ProxyAI/issues/1189) +- The credits label is now shown only for the ProxyAI provider + ## [3.7.4-241.1] - 2026-02-18 ### Added @@ -1282,7 +1300,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `OPENAI_API_KEY` persistence, key is saved in the OS password safe from now on -[Unreleased]: https://github.com/carlrobertoh/ProxyAI/compare/v3.7.4-241.1...HEAD +[Unreleased]: https://github.com/carlrobertoh/ProxyAI/compare/v3.7.5-241.1...HEAD +[3.7.5-241.1]: https://github.com/carlrobertoh/ProxyAI/compare/v3.7.4-241.1...v3.7.5-241.1 [3.7.4-241.1]: https://github.com/carlrobertoh/ProxyAI/compare/v3.7.3-241.1...v3.7.4-241.1 [3.7.3-241.1]: https://github.com/carlrobertoh/ProxyAI/compare/v3.7.2-241.1...v3.7.3-241.1 [3.7.2-241.1]: https://github.com/carlrobertoh/ProxyAI/compare/v3.7.1-241.1...v3.7.2-241.1 diff --git a/gradle.properties b/gradle.properties index e5edd43e..a7f4ca4f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = ee.carlrobert pluginName = ProxyAI pluginRepositoryUrl = https://github.com/carlrobertoh/ProxyAI # SemVer format -> https://semver.org -pluginVersion = 3.7.4 +pluginVersion = 3.7.5 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 241.1 diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/RetryingPromptExecutor.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/RetryingPromptExecutor.kt index 9b8ae955..0c8f305c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/RetryingPromptExecutor.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/clients/RetryingPromptExecutor.kt @@ -20,6 +20,8 @@ import ai.koog.prompt.params.LLMParams import ai.koog.prompt.streaming.StreamFrame import ee.carlrobert.codegpt.agent.AgentEvents import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.IOException +import java.net.SocketTimeoutException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay @@ -65,11 +67,29 @@ class RetryingPromptExecutor( } val causes = generateSequence(error) { it.cause }.take(10).toList() + if (causes.any { it.isRetryableTimeout() }) { + return true + } + val statusCode = causes .filterIsInstance() .firstNotNullOfOrNull { it.statusCode } return statusCode in RETRYABLE_HTTP_STATUS_CODES } + + private fun Throwable.isRetryableTimeout(): Boolean { + if (this is TimeoutCancellationException || this is SocketTimeoutException) { + return true + } + + return this is IOException && hasTimeoutMessage() + } + + private fun Throwable.hasTimeoutMessage(): Boolean { + val message = message ?: return false + return message.contains("timed out", ignoreCase = true) + || message.contains("timeout", ignoreCase = true) + } } private class RetryStreamingRequestException(cause: Throwable) : diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/WebSearchTool.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/WebSearchTool.kt index 1805d393..fd6d8037 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/WebSearchTool.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/WebSearchTool.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.jsoup.Jsoup +import java.net.URI +import java.net.URLDecoder import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.time.LocalDate @@ -62,7 +64,6 @@ IMPORTANT - Use the correct year in search queries: hookManager = hookManager, sessionId = sessionId, ) { - @Serializable data class Args( @property:LLMDescription( @@ -96,38 +97,19 @@ IMPORTANT - Use the correct year in search queries: ) override suspend fun doExecute(args: Args): Result = withContext(Dispatchers.IO) { - try { - val searxResults = searchWithSearxNG(args.query) - if (searxResults.isNotEmpty()) { - val filteredResults = - filterResults( - searxResults, - args.allowedDomains, - args.blockedDomains - ) - return@withContext Result( - query = args.query, - results = filteredResults.take(10), - sources = filteredResults.take(10).map { "[${it.title}](${it.url})" } - ) - } - } catch (_: Exception) { - // Fall back to DuckDuckGo - } - try { val duckduckgoResults = searchWithDuckDuckGo(args.query) - val filteredResults = - filterResults( - duckduckgoResults, - args.allowedDomains, - args.blockedDomains - ) - return@withContext Result( + val filteredResults = filterResults( + duckduckgoResults, + args.allowedDomains, + args.blockedDomains + ).take(10) + val result = Result( query = args.query, - results = filteredResults.take(10), - sources = filteredResults.take(10).map { "[${it.title}](${it.url})" } + results = filteredResults, + sources = filteredResults.map { "[${it.title}](${it.url})" } ) + return@withContext result } catch (_: Exception) { return@withContext Result( query = args.query, @@ -148,34 +130,6 @@ IMPORTANT - Use the correct year in search queries: ) } - private suspend fun searchWithSearxNG(query: String): List = - withContext(Dispatchers.IO) { - val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString()) - val url = "https://searx.space/search?q=$encodedQuery&format=json" - - val doc = Jsoup.connect(url) - .userAgent(userAgent) - .timeout(10000) - .ignoreContentType(true) - .get() - - val jsonResponse = doc.body().text() - if (jsonResponse.isEmpty()) return@withContext emptyList() - - try { - val searxResponse = json.decodeFromString(jsonResponse) - searxResponse.results.map { result -> - SearchResult( - title = result.title, - url = result.url, - content = result.content - ) - } - } catch (_: Exception) { - emptyList() - } - } - private suspend fun searchWithDuckDuckGo(query: String): List = withContext(Dispatchers.IO) { val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString()) @@ -193,10 +147,11 @@ IMPORTANT - Use the correct year in search queries: val snippet = resultDiv.selectFirst("a.result__snippet") if (titleLink != null) { + val resolvedUrl = normalizeSearchResultUrl(titleLink.attr("href")) results.add( SearchResult( title = titleLink.text(), - url = titleLink.attr("href"), + url = resolvedUrl, content = snippet?.text() ?: "" ) ) @@ -211,14 +166,16 @@ IMPORTANT - Use the correct year in search queries: allowedDomains: List?, blockedDomains: List? ): List { + val effectiveAllowedDomains = normalizeDomains(allowedDomains) + val effectiveBlockedDomains = normalizeDomains(blockedDomains) return results.filter { result -> val urlLower = result.url.lowercase() - blockedDomains?.any { domain -> + effectiveBlockedDomains?.any { domain -> urlLower.contains(domain.lowercase()) }?.let { if (it) return@filter false } - allowedDomains?.let { allowed -> + effectiveAllowedDomains?.let { allowed -> allowed.any { domain -> urlLower.contains(domain.lowercase()) } @@ -226,6 +183,44 @@ IMPORTANT - Use the correct year in search queries: } } + companion object { + internal fun normalizeSearchResultUrl(url: String): String { + if (url.isBlank()) return url + + val absoluteUrl = if (url.startsWith("//")) { + "https:$url" + } else { + url + } + + return runCatching { + val uri = URI(absoluteUrl) + val host = uri.host?.lowercase() + if (host != "duckduckgo.com" && host != "www.duckduckgo.com") { + return absoluteUrl + } + + val query = uri.rawQuery ?: return absoluteUrl + query.split("&") + .firstNotNullOfOrNull { segment -> + val idx = segment.indexOf('=') + if (idx <= 0) return@firstNotNullOfOrNull null + val key = segment.substring(0, idx) + if (key != "uddg") return@firstNotNullOfOrNull null + URLDecoder.decode(segment.substring(idx + 1), StandardCharsets.UTF_8) + } + ?: absoluteUrl + }.getOrDefault(absoluteUrl) + } + + internal fun normalizeDomains(domains: List?): List? { + return domains + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.takeIf { it.isNotEmpty() } + } + } + override fun encodeResultToString(result: Result): String = buildString { if (result.results.isEmpty()) { @@ -243,16 +238,4 @@ IMPORTANT - Use the correct year in search queries: appendLine() appendLine("*Click on any result to view the full content*") }.trimEnd().truncateToolResult() - - @Serializable - private data class SearxResponse( - val results: List - ) - - @Serializable - private data class SearxResult( - val title: String, - val url: String, - val content: String - ) } diff --git a/src/test/kotlin/ee/carlrobert/codegpt/agent/clients/RetryingPromptExecutorRetryabilityTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/agent/clients/RetryingPromptExecutorRetryabilityTest.kt index e548bec6..afca5ef6 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/agent/clients/RetryingPromptExecutorRetryabilityTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/agent/clients/RetryingPromptExecutorRetryabilityTest.kt @@ -3,6 +3,7 @@ package ee.carlrobert.codegpt.agent.clients import ai.koog.http.client.KoogHttpClientException import org.assertj.core.api.Assertions.assertThat import org.junit.Test +import java.io.IOException class RetryingPromptExecutorRetryabilityTest { @@ -33,6 +34,20 @@ class RetryingPromptExecutorRetryabilityTest { assertThat(RetryingPromptExecutor.isRetryableFailure(nested)).isTrue() } + @Test + fun `should retry when transport timeout is in nested cause chain`() { + val timeout = IOException("Operation timed out") + val nested = KoogHttpClientException( + "Error from client: InceptionAILLMClient\nMessage: Operation timed out", + null, + null, + null, + RuntimeException("sse wrapper", timeout) + ) + + assertThat(RetryingPromptExecutor.isRetryableFailure(nested)).isTrue() + } + @Test fun `should not retry when no HTTP status is present`() { assertThat(RetryingPromptExecutor.isRetryableFailure(RuntimeException("connection reset"))).isFalse()