From c8d961f5e0b937180220f048a692bab570faa224 Mon Sep 17 00:00:00 2001 From: Nabin_sharma <105613345+NabinSharmaitgithub@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:26:03 +0545 Subject: [PATCH 1/6] feat(ai): add custom OpenAI-compatible AI provider support Adds a configurable custom provider for the AI Agent: - New CustomProvider (OpenAI-compatible chat completions endpoint) - Configurable base URL, model name, and optional API key - Settings UI entries and sidebar configuration dialog - Registered in agent manager, model/provider selection, and API key helpers --- .../artificial/agents/AIAgentManager.kt | 2 + .../tom/rv2ide/artificial/agents/Agents.kt | 12 +- .../agents/custom/CustomProvider.kt | 462 ++++++++++++++++++ .../dialogs/CustomProviderConfigDialog.kt | 72 +++ .../tom/rv2ide/artificial/secrets/ApiKey.kt | 31 +- .../sidebar/AIPreferencesFragment.kt | 27 +- .../tom/rv2ide/preferences/aiAgentPrefExts.kt | 174 +++++++ .../layout/dialog_custom_provider_config.xml | 98 ++++ .../resources/src/main/res/values/strings.xml | 5 + 9 files changed, 870 insertions(+), 13 deletions(-) create mode 100644 core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt create mode 100644 core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomProviderConfigDialog.kt create mode 100644 core/app/src/main/res/layout/dialog_custom_provider_config.xml diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt index 14e9af4a68..a89fc0d8e3 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt @@ -24,6 +24,7 @@ import com.tom.rv2ide.artificial.agents.anthropic.Anthropic import com.tom.rv2ide.artificial.agents.grok.Grok import com.tom.rv2ide.artificial.agents.deepseek.DeepSeek import com.tom.rv2ide.artificial.agents.local.LocalLLM +import com.tom.rv2ide.artificial.agents.custom.CustomProvider import com.tom.rv2ide.artificial.file.FileWriteResult import com.tom.rv2ide.artificial.parser.SnippetParser import com.tom.rv2ide.artificial.permissions.AIPermissionManager @@ -53,6 +54,7 @@ class AIAgentManager(private val context: Context) { Grok.registerAgent() DeepSeek.registerAgent() LocalLLM.registerAgent() + CustomProvider.registerAgent() permissionManager.setFileWriteEnabled(true) permissionManager.setRequireConfirmation(false) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/Agents.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/Agents.kt index 8feae2ae5c..5341eae2c4 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/Agents.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/Agents.kt @@ -143,8 +143,12 @@ class Agents(ctx: Context) { private val localllm_models = arrayOf( "local-model" ) + + private val custom_models = arrayOf( + "custom-model" + ) - val ai_agents = openai_models + claude_models + gemini_models + deepseek_models + grok_models + localllm_models + val ai_agents = openai_models + claude_models + gemini_models + deepseek_models + grok_models + localllm_models + custom_models fun getModelsForProvider(providerId: String): Array { return when(providerId) { @@ -154,6 +158,7 @@ class Agents(ctx: Context) { "deepseek" -> deepseek_models "grok" -> grok_models "localllm" -> localllm_models + "custom" -> custom_models else -> gemini_models } } @@ -166,6 +171,7 @@ class Agents(ctx: Context) { modelName in deepseek_models -> "deepseek" modelName in grok_models -> "grok" modelName in localllm_models -> "localllm" + modelName in custom_models -> "custom" else -> null } } @@ -177,6 +183,7 @@ class Agents(ctx: Context) { name in claude_models -> "claude" name in deepseek_models -> "deepseek" name in grok_models -> "grok" + name in custom_models -> "custom" else -> sp.getString(PROVIDER_KEY, "gemini") ?: "gemini" } @@ -194,6 +201,7 @@ class Agents(ctx: Context) { "claude" -> "claude-sonnet-4-20250514" "deepseek" -> "deepseek-chat" "grok" -> "grok-beta" + "custom" -> "custom-model" else -> "gemini-2.5-pro" } } @@ -209,4 +217,4 @@ class Agents(ctx: Context) { fun isValidModelForProvider(modelName: String, providerId: String): Boolean { return modelName in getModelsForProvider(providerId) } -} \ No newline at end of file +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt new file mode 100644 index 0000000000..b6f91a6730 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt @@ -0,0 +1,462 @@ +/* + * This file is part of AndroidCodeStudio. + * + * AndroidCodeStudio is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidCodeStudio is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidCodeStudio. If not, see . +*/ + +package com.tom.rv2ide.artificial.agents.custom + +import android.content.Context +import com.tom.rv2ide.app.BaseApplication +import com.tom.rv2ide.artificial.agents.AIAgent +import com.tom.rv2ide.artificial.agents.AIAgentRegistry +import com.tom.rv2ide.artificial.agents.Agents +import com.tom.rv2ide.artificial.agents.ModificationAttempt +import com.tom.rv2ide.artificial.file.AIFileWriter +import com.tom.rv2ide.artificial.file.FileWriteResult +import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult +import com.tom.rv2ide.artificial.rules.WritingRules +import java.io.File +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject + +/* + * Custom OpenAI-compatible provider support. + * Configure base URL, model name, and optional API key in AI settings. + */ +class CustomProvider : AIAgent { + + private var baseUrl: String? = null + private var modelName: String? = null + private var apiKey: String? = null + private val httpClient = + OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + private val writingRules = WritingRules.Instructions() + private var projectTreeResult: ProjectTreeResult? = null + private var fileWriter: AIFileWriter? = null + private val conversationHistory = mutableListOf() + private val modificationHistory = mutableListOf() + private var currentAttemptCount = 0 + private val maxRetryAttempts = 3 + private var agents: Agents? = null + override val providerId = "custom" + override val providerName = "Custom Provider" + + companion object { + private const val PREF_BASE_URL = "ai_agent_custom_base_url" + private const val PREF_MODEL_NAME = "ai_agent_custom_model_name" + private const val PREF_API_KEY = "ai_agent_custom_api_key" + + fun registerAgent() { + AIAgentRegistry.register( + "custom", + object : AIAgentRegistry.AgentFactory { + override fun create(context: Context): AIAgent { + return CustomProvider() + } + + override fun hasValidApiKey(): Boolean { + val prefs = BaseApplication.getBaseInstance().prefManager + val url = prefs.getString(PREF_BASE_URL, null) + val model = prefs.getString(PREF_MODEL_NAME, null) + android.util.Log.d("CustomProvider", "hasValidApiKey check: url=$url, model=$model") + return !url.isNullOrEmpty() && !model.isNullOrEmpty() + } + + override fun getApiKey(): String? { + val prefs = BaseApplication.getBaseInstance().prefManager + val key = prefs.getString(PREF_API_KEY, null) + return if (key.isNullOrBlank()) "custom" else key + } + }) + } + + private fun buildEndpoint(baseUrl: String): String { + val trimmed = baseUrl.trim().trimEnd('/') + return when { + trimmed.contains("/chat/completions") -> trimmed + trimmed.endsWith("/v1") -> "$trimmed/chat/completions" + else -> "$trimmed/v1/chat/completions" + } + } + } + + override fun initialize(apiKey: String, context: Context) { + try { + agents = Agents(context) + val prefs = BaseApplication.getBaseInstance().prefManager + baseUrl = prefs.getString(PREF_BASE_URL, null) + modelName = prefs.getString(PREF_MODEL_NAME, null) + this.apiKey = prefs.getString(PREF_API_KEY, null) + + android.util.Log.d( + "CustomProvider", "Initialized with baseUrl=$baseUrl, model=$modelName, hasKey=${!this.apiKey.isNullOrBlank()}") + + if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) { + throw IllegalStateException( + "Custom provider not configured. Please set base URL and model name in AI settings.") + } + } catch (e: Exception) { + throw e + } + } + + override fun isInitialized(): Boolean = + !baseUrl.isNullOrEmpty() && !modelName.isNullOrEmpty() + + override fun reinitializeWithNewModel(apiKey: String, context: Context) { + initialize(apiKey, context) + } + + override fun setContext(context: Context) { + fileWriter = AIFileWriter(context) + } + + override fun setProjectData(projectTreeResult: ProjectTreeResult) { + this.projectTreeResult = projectTreeResult + } + + override fun clearConversation() { + conversationHistory.clear() + modificationHistory.clear() + currentAttemptCount = 0 + } + + override fun recordModification( + filePath: String, + oldContent: String?, + newContent: String, + success: Boolean + ) { + modificationHistory.add( + ModificationAttempt( + timestamp = System.currentTimeMillis(), + filePath = filePath, + previousContent = oldContent, + newContent = newContent, + attemptNumber = currentAttemptCount, + success = success)) + } + + override fun undoLastModification(): Boolean { + if (modificationHistory.isEmpty()) return false + + val lastMod = modificationHistory.lastOrNull { it.success } ?: return false + + if (lastMod.previousContent != null) { + val result = writeFile(lastMod.filePath, lastMod.previousContent) + if (result is FileWriteResult.Success) { + modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)) + return true + } + } else { + try { + File(lastMod.filePath).delete() + modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)) + return true + } catch (e: Exception) { + return false + } + } + return false + } + + override fun getModificationHistory(): List { + return modificationHistory.toList() + } + + override fun resetAttemptCount() { + currentAttemptCount = 0 + } + + override fun incrementAttemptCount() { + currentAttemptCount++ + } + + override fun getCurrentAttemptCount(): Int = currentAttemptCount + + override fun canRetry(): Boolean = currentAttemptCount < maxRetryAttempts + + private fun isUserRequestingCorrection(message: String): Boolean { + val correctionKeywords = + listOf( + "wrong", + "not what", + "mistake", + "error", + "incorrect", + "that's not", + "not right", + "fix", + "undo", + "revert", + "different", + "try again", + "not working") + return correctionKeywords.any { message.lowercase().contains(it) } + } + + override suspend fun generateCode( + prompt: String, + context: String?, + language: String, + projectStructure: String?, + ): Result = + withContext(Dispatchers.IO) { + try { + if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) { + return@withContext Result.failure(IllegalStateException("Custom provider not configured")) + } + + val fileContents = readRelevantFiles() + val needsCorrection = isUserRequestingCorrection(prompt) + + val fullPrompt = buildString { + append("=== PROJECT STRUCTURE (THESE ARE THE EXACT PATHS YOU MUST USE) ===\n") + if (projectTreeResult != null) { + append(projectTreeResult!!.tree) + append("\n\n") + append( + "CRITICAL: Use ONLY the paths shown above. Do NOT make up fake paths like '/storage/emulated/0/project' or 'com.example.yourproject'.\n") + append("CRITICAL: Look at the actual paths above and use those EXACT paths.\n\n") + } + + if (fileContents.isNotEmpty()) { + append("=== CURRENT FILES CONTENT ===\n") + fileContents.forEach { (path, content) -> + append("FILE: $path\n") + append("CONTENT:\n") + append(content) + append("\n\n") + } + } + + if (context != null) { + append("=== ADDITIONAL CONTEXT ===\n") + append(context) + append("\n\n") + } + + if (conversationHistory.isNotEmpty()) { + append("=== CONVERSATION HISTORY ===\n") + conversationHistory.forEach { msg -> append("${msg.role.uppercase()}: ${msg.content}\n\n") } + } + + if (needsCorrection && modificationHistory.isNotEmpty()) { + append("=== CORRECTION REQUIRED ===\n") + append("The user indicated the previous modification was WRONG.\n") + append("Previous failed attempts:\n") + modificationHistory.takeLast(3).forEach { attempt -> + append("Attempt ${attempt.attemptNumber}: ${attempt.filePath}\n") + append("Result: ${if (attempt.success) "Applied but user rejected" else "Failed"}\n\n") + } + append("You MUST try a DIFFERENT approach. Do NOT repeat the same solution.\n") + append("Analyze what went wrong and provide a better solution.\n\n") + } + + if (currentAttemptCount > 0) { + append("=== RETRY ATTEMPT $currentAttemptCount/$maxRetryAttempts ===\n") + append("This is retry attempt number $currentAttemptCount.\n") + append("Previous attempts did not satisfy the user.\n") + append("Think carefully and provide a different solution.\n\n") + } + + append("=== USER REQUEST ===\n") + append(prompt) + } + + val messages = JSONArray() + messages.put( + JSONObject().apply { + put("role", "system") + put("content", writingRules.useThis()) + }) + messages.put( + JSONObject().apply { + put("role", "user") + put("content", fullPrompt) + }) + + val requestBody = + JSONObject().apply { + put("model", modelName) + put("messages", messages) + put("temperature", 0.7) + put("stream", false) + } + + val endpoint = buildEndpoint(baseUrl!!) + val requestBuilder = + Request.Builder() + .url(endpoint) + .post(requestBody.toString().toRequestBody("application/json".toMediaType())) + .addHeader("Content-Type", "application/json") + + val key = apiKey?.trim() + if (!key.isNullOrEmpty()) { + requestBuilder.addHeader("Authorization", "Bearer $key") + } + + val response = + try { + httpClient.newCall(requestBuilder.build()).execute() + } catch (e: Exception) { + val errorMessage = e.message ?: "" + when { + errorMessage.contains("timeout") || errorMessage.contains("connect") -> + throw com.tom.rv2ide.artificial.exceptions.RateLimitException( + "Connection timeout. Please check your custom provider endpoint.") + else -> throw e + } + } + + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + return@withContext Result.failure( + Exception("Custom provider error ${response.code}: $errorBody")) + } + + val responseBody = response.body?.string() ?: "" + val jsonResponse = JSONObject(responseBody) + + val generatedResponse = + jsonResponse.getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content") + + if (generatedResponse.isBlank()) { + return@withContext Result.failure(Exception("Empty response from custom provider")) + } + + conversationHistory.add(ConversationMessage("user", prompt)) + conversationHistory.add(ConversationMessage("assistant", generatedResponse)) + + if (conversationHistory.size > 20) { + conversationHistory.removeAt(0) + conversationHistory.removeAt(0) + } + + Result.success(generatedResponse) + } catch (e: com.tom.rv2ide.artificial.exceptions.RateLimitException) { + Result.failure(e) + } catch (e: com.tom.rv2ide.artificial.exceptions.QuotaExceededException) { + Result.failure(e) + } catch (e: com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException) { + Result.failure(e) + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun readRelevantFiles(): Map { + val filesContent = mutableMapOf() + val tree = projectTreeResult?.tree ?: return filesContent + + val filePaths = tree.lines().filter { it.isNotBlank() } + + filePaths.forEach { filePath -> + val trimmedPath = filePath.trim() + val file = File(trimmedPath) + + if (file.isFile && + (trimmedPath.endsWith(".kt") || + trimmedPath.endsWith(".java") || + trimmedPath.endsWith(".xml") || + trimmedPath.endsWith(".gradle") || + trimmedPath.endsWith(".gradle.kts")) && + !trimmedPath.contains("/build/") && + !trimmedPath.contains("/.gradle/")) { + try { + val content = file.readText() + filesContent[trimmedPath] = content + } catch (e: Exception) {} + } + } + + return filesContent + } + + override fun writeFile(filePath: String, content: String): FileWriteResult { + val writer = fileWriter ?: return FileWriteResult.Error("File writer not initialized") + return writer.writeFile(filePath, content, createBackup = true) + } + + fun parseAndApplyModifications( + response: String, + capturedStates: Map + ): List { + val modifications = mutableListOf() + val parser = com.tom.rv2ide.artificial.parser.SnippetParser() + + if (response.contains("FILE_TO_MODIFY:")) { + val lines = response.lines() + var currentFile: String? = null + val contentBuilder = StringBuilder() + var inContent = false + + for (line in lines) { + if (line.startsWith("FILE_TO_MODIFY:")) { + if (currentFile != null && contentBuilder.isNotEmpty()) { + val rawContent = contentBuilder.toString().trim() + val cleanedContent = parser.cleanFileContent(rawContent) + val previousContent = capturedStates[currentFile] + val writeResult = writeFile(currentFile, cleanedContent) + + val success = writeResult is FileWriteResult.Success + recordModification(currentFile, previousContent, cleanedContent, success) + + modifications.add(FileModification(currentFile, cleanedContent, writeResult)) + } + + currentFile = line.substringAfter("FILE_TO_MODIFY:").trim() + contentBuilder.clear() + inContent = true + } else if (inContent) { + contentBuilder.append(line).append("\n") + } + } + + if (currentFile != null && contentBuilder.isNotEmpty()) { + val rawContent = contentBuilder.toString().trim() + val cleanedContent = parser.cleanFileContent(rawContent) + val previousContent = capturedStates[currentFile] + val writeResult = writeFile(currentFile, cleanedContent) + + val success = writeResult is FileWriteResult.Success + recordModification(currentFile, previousContent, cleanedContent, success) + + modifications.add(FileModification(currentFile, cleanedContent, writeResult)) + } + } + + return modifications + } +} + +data class ConversationMessage(val role: String, val content: String) + +data class FileModification( + val filePath: String, + val content: String, + val writeResult: FileWriteResult +) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomProviderConfigDialog.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomProviderConfigDialog.kt new file mode 100644 index 0000000000..97a1b90666 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomProviderConfigDialog.kt @@ -0,0 +1,72 @@ +package com.tom.rv2ide.artificial.dialogs + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.textfield.TextInputEditText +import com.tom.rv2ide.R +import com.tom.rv2ide.app.BaseApplication + +class CustomProviderConfigDialog( + private val onSave: (baseUrl: String, modelName: String, apiKey: String) -> Unit +) : BottomSheetDialogFragment() { + + private lateinit var baseUrlInput: TextInputEditText + private lateinit var modelNameInput: TextInputEditText + private lateinit var apiKeyInput: TextInputEditText + private lateinit var saveButton: Button + private lateinit var cancelButton: Button + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.dialog_custom_provider_config, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + baseUrlInput = view.findViewById(R.id.baseUrlInput) + modelNameInput = view.findViewById(R.id.modelNameInput) + apiKeyInput = view.findViewById(R.id.apiKeyInput) + saveButton = view.findViewById(R.id.saveButton) + cancelButton = view.findViewById(R.id.cancelButton) + + loadSavedConfig() + + saveButton.setOnClickListener { + val baseUrl = baseUrlInput.text.toString().trim() + val modelName = modelNameInput.text.toString().trim() + val apiKey = apiKeyInput.text.toString().trim() + + if (baseUrl.isNotEmpty() && modelName.isNotEmpty()) { + saveConfig(baseUrl, modelName, apiKey) + onSave(baseUrl, modelName, apiKey) + dismiss() + } + } + + cancelButton.setOnClickListener { + dismiss() + } + } + + private fun loadSavedConfig() { + val prefs = BaseApplication.getBaseInstance().prefManager + baseUrlInput.setText(prefs.getString("ai_agent_custom_base_url", "https://api.openai.com")) + modelNameInput.setText(prefs.getString("ai_agent_custom_model_name", "gpt-4o-mini")) + apiKeyInput.setText(prefs.getString("ai_agent_custom_api_key", "")) + } + + private fun saveConfig(baseUrl: String, modelName: String, apiKey: String) { + val prefs = BaseApplication.getBaseInstance().prefManager + prefs.putString("ai_agent_custom_base_url", baseUrl) + prefs.putString("ai_agent_custom_model_name", modelName) + prefs.putString("ai_agent_custom_api_key", apiKey) + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/secrets/ApiKey.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/secrets/ApiKey.kt index 4b800951da..1829899c86 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/secrets/ApiKey.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/secrets/ApiKey.kt @@ -78,6 +78,29 @@ object ApiKey { val key = getGrokApiKey() return key.isNotBlank() && key.length > 20 } + + // Custom Provider (OpenAI-compatible) + fun getCustomApiKey(): String { + return prefManager.getString("ai_agent_custom_api_key", "") + } + + fun getCustomBaseUrl(): String { + return prefManager.getString("ai_agent_custom_base_url", "") + } + + fun getCustomModelName(): String { + return prefManager.getString("ai_agent_custom_model_name", "") + } + + fun hasCustomKey(): Boolean { + // Custom provider can work without an API key; configuration is URL + model. + val key = getCustomApiKey() + return key.isNotBlank() + } + + fun hasCustomProviderConfig(): Boolean { + return getCustomBaseUrl().isNotBlank() && getCustomModelName().isNotBlank() + } // Legacy methods for backward compatibility @Deprecated("Use getGeminiApiKey() instead", ReplaceWith("getGeminiApiKey()")) @@ -93,6 +116,7 @@ object ApiKey { if (hasDeepseekKey()) providers.add("Deepseek") if (hasAnthropicKey()) providers.add("Anthropic") if (hasGrokKey()) providers.add("Grok") + if (hasCustomProviderConfig()) providers.add("Custom") return providers } @@ -103,13 +127,14 @@ object ApiKey { "openai" to getOpenAIApiKey(), "deepseek" to getDeepseekApiKey(), "anthropic" to getAnthropicApiKey(), - "grok" to getGrokApiKey() + "grok" to getGrokApiKey(), + "custom" to getCustomApiKey() ).filterValues { it.isNotBlank() } } // Check if any API key is configured fun hasAnyApiKey(): Boolean { return hasGeminiKey() || hasOpenAIKey() || hasDeepseekKey() || - hasAnthropicKey() || hasGrokKey() + hasAnthropicKey() || hasGrokKey() || hasCustomProviderConfig() } -} \ No newline at end of file +} diff --git a/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt b/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt index 4912b3da81..1731b2d47c 100644 --- a/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt +++ b/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import com.tom.rv2ide.artificial.dialogs.LocalLLMConfigDialog +import com.tom.rv2ide.artificial.dialogs.CustomProviderConfigDialog class AIPreferencesFragment( private val aiAgent: AIAgentManager, @@ -89,10 +90,11 @@ class AIPreferencesFragment( "claude" to "Anthropic Claude", "deepseek" to "DeepSeek", "grok" to "xAI Grok", - "localllm" to "Local LLM" + "localllm" to "Local LLM", + "custom" to "Custom Provider" ) - val allProviderIds = listOf("gemini", "openai", "claude", "deepseek", "grok", "localllm") + val allProviderIds = listOf("gemini", "openai", "claude", "deepseek", "grok", "localllm", "custom") val providerNames = allProviderIds.map { providerMap[it] ?: it } val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, providerNames) @@ -104,10 +106,10 @@ class AIPreferencesFragment( val selectedProviderId = allProviderIds[position] val selectedProviderName = providerNames[position] - if (selectedProviderId == "localllm") { - showLocalLLMConfigDialog(selectedProviderName) - } else { - handleProviderChange(selectedProviderId, selectedProviderName) + when (selectedProviderId) { + "localllm" -> showLocalLLMConfigDialog(selectedProviderName) + "custom" -> showCustomProviderConfigDialog(selectedProviderName) + else -> handleProviderChange(selectedProviderId, selectedProviderName) } } } @@ -118,6 +120,13 @@ class AIPreferencesFragment( } dialog.show(parentFragmentManager, "LocalLLMConfigDialog") } + + private fun showCustomProviderConfigDialog(providerName: String) { + val dialog = CustomProviderConfigDialog { baseUrl, modelName, apiKey -> + handleProviderChange("custom", providerName) + } + dialog.show(parentFragmentManager, "CustomProviderConfigDialog") + } private fun updateProviderDropdownSelection() { val providerMap = mapOf( @@ -126,7 +135,8 @@ class AIPreferencesFragment( "claude" to "Anthropic Claude", "deepseek" to "DeepSeek", "grok" to "xAI Grok", - "localllm" to "Local LLM" + "localllm" to "Local LLM", + "custom" to "Custom Provider" ) val currentProviderId = agents.getProvider() @@ -147,6 +157,7 @@ class AIPreferencesFragment( "deepseek" -> "DeepSeek" "grok" -> "xAI Grok" "localllm" -> "Local LLM" + "custom" -> "Custom Provider" else -> currentProvider.uppercase() } @@ -335,4 +346,4 @@ class AIPreferencesFragment( stopCompletionStateMonitoring() super.onDestroyView() } -} \ No newline at end of file +} diff --git a/core/app/src/main/java/com/tom/rv2ide/preferences/aiAgentPrefExts.kt b/core/app/src/main/java/com/tom/rv2ide/preferences/aiAgentPrefExts.kt index fe3c8eb8f0..9e3a44acaf 100644 --- a/core/app/src/main/java/com/tom/rv2ide/preferences/aiAgentPrefExts.kt +++ b/core/app/src/main/java/com/tom/rv2ide/preferences/aiAgentPrefExts.kt @@ -51,6 +51,9 @@ private class AIAgentConfig( @IgnoredOnParcel private var openAIApiKeyPref: OpenAIApiKey? = null @IgnoredOnParcel private var anthropicApiKeyPref: AnthropicApiKey? = null @IgnoredOnParcel private var grokApiKeyPref: GrokApiKey? = null + @IgnoredOnParcel private var customBaseUrlPref: CustomBaseUrl? = null + @IgnoredOnParcel private var customModelNamePref: CustomModelName? = null + @IgnoredOnParcel private var customApiKeyPref: CustomApiKey? = null init { val aiAgentEnabled = AIAgentEnabled { isEnabled -> updateApiKeyPreferencesState(isEnabled) } @@ -60,6 +63,9 @@ private class AIAgentConfig( openAIApiKeyPref = OpenAIApiKey() anthropicApiKeyPref = AnthropicApiKey() grokApiKeyPref = GrokApiKey() + customBaseUrlPref = CustomBaseUrl() + customModelNamePref = CustomModelName() + customApiKeyPref = CustomApiKey() addPreference(aiAgentEnabled) addPreference(geminiApiKeyPref!!) @@ -67,6 +73,9 @@ private class AIAgentConfig( addPreference(openAIApiKeyPref!!) addPreference(anthropicApiKeyPref!!) addPreference(grokApiKeyPref!!) + addPreference(customBaseUrlPref!!) + addPreference(customModelNamePref!!) + addPreference(customApiKeyPref!!) } private fun updateApiKeyPreferencesState(isEnabled: Boolean) { @@ -75,6 +84,9 @@ private class AIAgentConfig( openAIApiKeyPref?.setEnabled(isEnabled) anthropicApiKeyPref?.setEnabled(isEnabled) grokApiKeyPref?.setEnabled(isEnabled) + customBaseUrlPref?.setEnabled(isEnabled) + customModelNamePref?.setEnabled(isEnabled) + customApiKeyPref?.setEnabled(isEnabled) } } @@ -372,3 +384,165 @@ private class AnthropicApiKey( return if (apiKey.isBlank()) "Click to set API key" else "API Key: ${apiKey.take(8)}..." } } + +@Parcelize +private class CustomBaseUrl( + override val key: String = "ai_agent_custom_base_url", + override val title: Int = R.string.ai_agent_custom_base_url, +) : BasePreference() { + + @IgnoredOnParcel private var preference: Preference? = null + + override fun onCreatePreference(context: Context): Preference { + preference = + androidx.preference.Preference(context).apply { + key = "ai_agent_custom_base_url" + title = context.getString(R.string.ai_agent_custom_base_url) + summary = getSummaryText() + isEnabled = prefManager.getBoolean("ai_agent_enabled", false) + } + return preference!! + } + + override fun onPreferenceClick(preference: Preference): Boolean { + val context = preference.context + + val editText = android.widget.EditText(context) + editText.setText(prefManager.getString("ai_agent_custom_base_url", "")) + editText.hint = "https://api.openai.com" + + val dialog = + com.google.android.material.dialog + .MaterialAlertDialogBuilder(context) + .setTitle("Custom Provider Base URL") + .setMessage("Enter the OpenAI-compatible base URL") + .setView(editText) + .setPositiveButton("Save") { _, _ -> + val value = editText.text.toString().trim() + prefManager.putString("ai_agent_custom_base_url", value) + preference.summary = getSummaryText() + } + .setNegativeButton("Cancel", null) + .create() + + dialog.show() + return true + } + + fun setEnabled(enabled: Boolean) { + preference?.isEnabled = enabled + } + + private fun getSummaryText(): String { + val value = prefManager.getString("ai_agent_custom_base_url", "") + return if (value.isBlank()) "Click to set base URL" else value + } +} + +@Parcelize +private class CustomModelName( + override val key: String = "ai_agent_custom_model_name", + override val title: Int = R.string.ai_agent_custom_model_name, +) : BasePreference() { + + @IgnoredOnParcel private var preference: Preference? = null + + override fun onCreatePreference(context: Context): Preference { + preference = + androidx.preference.Preference(context).apply { + key = "ai_agent_custom_model_name" + title = context.getString(R.string.ai_agent_custom_model_name) + summary = getSummaryText() + isEnabled = prefManager.getBoolean("ai_agent_enabled", false) + } + return preference!! + } + + override fun onPreferenceClick(preference: Preference): Boolean { + val context = preference.context + + val editText = android.widget.EditText(context) + editText.setText(prefManager.getString("ai_agent_custom_model_name", "")) + editText.hint = "gpt-4o-mini" + + val dialog = + com.google.android.material.dialog + .MaterialAlertDialogBuilder(context) + .setTitle("Custom Provider Model") + .setMessage("Enter the model name") + .setView(editText) + .setPositiveButton("Save") { _, _ -> + val value = editText.text.toString().trim() + prefManager.putString("ai_agent_custom_model_name", value) + preference.summary = getSummaryText() + } + .setNegativeButton("Cancel", null) + .create() + + dialog.show() + return true + } + + fun setEnabled(enabled: Boolean) { + preference?.isEnabled = enabled + } + + private fun getSummaryText(): String { + val value = prefManager.getString("ai_agent_custom_model_name", "") + return if (value.isBlank()) "Click to set model name" else value + } +} + +@Parcelize +private class CustomApiKey( + override val key: String = "ai_agent_custom_api_key", + override val title: Int = R.string.ai_agent_custom_api_key, +) : BasePreference() { + + @IgnoredOnParcel private var preference: Preference? = null + + override fun onCreatePreference(context: Context): Preference { + preference = + androidx.preference.Preference(context).apply { + key = "ai_agent_custom_api_key" + title = context.getString(R.string.ai_agent_custom_api_key) + summary = getSummaryText() + isEnabled = prefManager.getBoolean("ai_agent_enabled", false) + } + return preference!! + } + + override fun onPreferenceClick(preference: Preference): Boolean { + val context = preference.context + + val editText = android.widget.EditText(context) + editText.setText(prefManager.getString("ai_agent_custom_api_key", "")) + editText.hint = "Enter API key (optional)" + + val dialog = + com.google.android.material.dialog + .MaterialAlertDialogBuilder(context) + .setTitle("Custom Provider API Key") + .setMessage("Enter an API key if your endpoint requires one") + .setView(editText) + .setPositiveButton("Save") { _, _ -> + val apiKey = editText.text.toString().trim() + prefManager.putString("ai_agent_custom_api_key", apiKey) + preference.summary = getSummaryText() + } + .setNegativeButton("Cancel", null) + .create() + + dialog.show() + return true + } + + fun setEnabled(enabled: Boolean) { + preference?.isEnabled = enabled + } + + private fun getSummaryText(): String { + val apiKey = prefManager.getString("ai_agent_custom_api_key", "") + return if (apiKey.isBlank()) "Click to set API key (optional)" else "API Key: ${apiKey.take(8)}..." + } +} diff --git a/core/app/src/main/res/layout/dialog_custom_provider_config.xml b/core/app/src/main/res/layout/dialog_custom_provider_config.xml new file mode 100644 index 0000000000..708569fc66 --- /dev/null +++ b/core/app/src/main/res/layout/dialog_custom_provider_config.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index b56eec7f6d..9afc60a796 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -86,6 +86,11 @@ Grok API Key + + Custom Provider Base URL + Custom Provider Model Name + Custom Provider API Key + ACS Components Missing This application appears to be incomplete or unofficial, as it is missing the required ACS build system. For full functionality, please install the official Android Code Studio from a trusted source. From 372721434ec5541f2f9c52fd8ffca10b59775ef9 Mon Sep 17 00:00:00 2001 From: Nabin_sharma <105613345+NabinSharmaitgithub@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:48:05 +0545 Subject: [PATCH 2/6] feat(ai): add provider profiles, agent profiles/skills, tools, and MCP foundation - Multiple saved custom OpenAI-compatible provider profiles (add/edit/delete/activate) with legacy migration - Reusable agent prompt profiles + prebuilt skills (file reader, command run [opt-in], file modification, Stitch, design, custom) - Built-in agent tools/capabilities (read_file, list_files, search_project, get_project_tree, modify_file, run_command gated) - MCP client foundation (HTTP JSON-RPC/SSE-compatible), server config storage, and prebuilt MCP presets (GitHub, Firebase, Stitch, Vercel, Cloudflare, Supabase, custom) - Agent loop integration in AIAgentManager for text-based TOOL_CALL/TOOL_RESULT - Sidebar AI Preferences UI for agent profiles and MCP servers --- .../artificial/agents/AIAgentManager.kt | 488 ++++++------------ .../agents/custom/CustomProvider.kt | 442 +++++----------- .../artificial/dialogs/AgentProfilesDialog.kt | 91 ++++ .../dialogs/CustomProviderConfigDialog.kt | 130 ++++- .../artificial/dialogs/McpServersDialog.kt | 91 ++++ .../tom/rv2ide/artificial/mcp/McpClient.kt | 140 +++++ .../tom/rv2ide/artificial/mcp/McpModels.kt | 81 +++ .../artificial/profiles/AgentPromptProfile.kt | 121 +++++ .../profiles/CustomProviderProfile.kt | 124 +++++ .../tom/rv2ide/artificial/tools/AITools.kt | 150 ++++++ .../sidebar/AIPreferencesFragment.kt | 8 + .../layout/dialog_custom_provider_config.xml | 72 ++- .../res/layout/fragment_ai_preferences.xml | 37 +- 13 files changed, 1259 insertions(+), 716 deletions(-) create mode 100644 core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/AgentProfilesDialog.kt create mode 100644 core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/McpServersDialog.kt create mode 100644 core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt create mode 100644 core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpModels.kt create mode 100644 core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt create mode 100644 core/app/src/main/java/com/tom/rv2ide/artificial/profiles/CustomProviderProfile.kt create mode 100644 core/app/src/main/java/com/tom/rv2ide/artificial/tools/AITools.kt diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt index a89fc0d8e3..d7d08d0377 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt @@ -26,10 +26,15 @@ import com.tom.rv2ide.artificial.agents.deepseek.DeepSeek import com.tom.rv2ide.artificial.agents.local.LocalLLM import com.tom.rv2ide.artificial.agents.custom.CustomProvider import com.tom.rv2ide.artificial.file.FileWriteResult +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor import com.tom.rv2ide.artificial.parser.SnippetParser import com.tom.rv2ide.artificial.permissions.AIPermissionManager +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore import com.tom.rv2ide.artificial.project.awareness.ProjectData -import com.tom.rv2ide.artificial.secrets.ApiKey +import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.tools.ToolCallProtocol import java.io.File import kotlinx.coroutines.delay import com.tom.rv2ide.artificial.dialogs.ProviderSwitchDialog @@ -43,6 +48,7 @@ class AIAgentManager(private val context: Context) { private val snippetParser = SnippetParser() private val permissionManager = AIPermissionManager(context) private var currentProjectRoot: File? = null + private var currentProjectTree: ProjectTreeResult? = null private var currentProviderId: String = "gemini" private var currentAgent: AIAgent? = null private val providerSwitchDialog = ProviderSwitchDialog(context) @@ -86,13 +92,7 @@ class AIAgentManager(private val context: Context) { android.util.Log.d("AIAgentManager", "Initializing agent with API key") currentAgent?.initialize(apiKey, context) currentAgent?.setContext(context) - - currentProjectRoot?.let { root -> - val projectData = ProjectData(context) - val projectTree = projectData.showProjectTree(root) - currentAgent?.setProjectData(projectTree) - } - + currentProjectTree?.let { currentAgent?.setProjectData(it) } android.util.Log.d("AIAgentManager", "Agent initialized: ${currentAgent?.isInitialized()}") } @@ -100,193 +100,148 @@ class AIAgentManager(private val context: Context) { } fun getCurrentProviderId(): String = currentProviderId - - fun getCurrentProviderName(): String { - return currentAgent?.providerName ?: "Unknown" - } + fun getCurrentProviderName(): String = currentAgent?.providerName ?: "Unknown" fun getAvailableProviders(): List { return AIAgentRegistry.getAvailableProviders().mapNotNull { providerId -> val factory = AIAgentRegistry.getFactory(providerId) val agent = factory?.create(context) - agent?.let { - ProviderInfo( - id = it.providerId, - name = it.providerName, - isAvailable = factory.hasValidApiKey() - ) - } + agent?.let { ProviderInfo(id = it.providerId, name = it.providerName, isAvailable = factory.hasValidApiKey()) } } } fun setProjectRoot(projectPath: String): Boolean { val projectRoot = File(projectPath) if (!projectRoot.exists()) return false - currentProjectRoot = projectRoot val projectData = ProjectData(context) val projectTree = projectData.showProjectTree(projectRoot) - + currentProjectTree = projectTree currentAgent?.setProjectData(projectTree) permissionManager.addAllowedDirectory(projectRoot.absolutePath) - return true } - fun clearConversation() { - currentAgent?.clearConversation() + fun clearConversation() { currentAgent?.clearConversation() } + + private fun executeToolCall(name: String, args: org.json.JSONObject): String { + val profile = AgentPromptProfileStore.getActive() + if (name.startsWith("mcp__")) { + if (profile?.enableMcpTools == false) return "Error: MCP tools disabled by active agent profile" + val servers = McpServerStore.load().filter { s -> + profile?.enabledMcpServerIds.isNullOrEmpty() || profile!!.enabledMcpServerIds.contains(s.id) + } + return McpToolExecutor(servers).execute(name, args) + } + if (profile?.enableTools == false) return "Error: built-in tools disabled by active agent profile" + val builtIn = BuiltInToolExecutor(context, currentProjectTree, currentProjectRoot, profile?.allowCommandExecution == true) + return builtIn.execute(name, args) } suspend fun executeRequest(userRequest: String, callback: AIAgentCallback) { var success = false var providerSwitched = false - currentAgent?.resetAttemptCount() callback.onProcessing("Analyzing your request...") while (!success && (currentAgent?.canRetry() == true)) { try { val currentAttempt = currentAgent?.getCurrentAttemptCount() ?: 0 - if (currentAttempt > 0 && !providerSwitched) { callback.onRetry(currentAttempt, "Thinking differently...") delay(1000) } val previousFileStates = captureCurrentFileStates() + var promptForModel = userRequest + var toolIterations = 0 + val maxToolIterations = 6 + var finalResponse: String? = null + + while (toolIterations < maxToolIterations) { + val result = currentAgent?.generateCode(promptForModel, null, "kotlin", null) + ?: Result.failure(Exception("No agent initialized")) + + val response = result.getOrElse { throw it } + val toolCall = ToolCallProtocol.parse(response) + if (toolCall != null) { + toolIterations++ + callback.onProcessing("Using tool: ${toolCall.name} (${toolIterations}/$maxToolIterations)") + val toolResult = try { executeToolCall(toolCall.name, toolCall.arguments) } catch (e: Exception) { "Error: ${e.message}" } + val clipped = if (toolResult.length > 8000) toolResult.take(8000) + "\n...[truncated]" else toolResult + promptForModel = "TOOL_RESULT for ${toolCall.name}:\n$clipped\n\nContinue with the user's request. If you need another tool, emit TOOL_CALL. Otherwise provide the final answer and use FILE_TO_MODIFY for any file edits." + continue + } + finalResponse = response + break + } - val result = currentAgent?.generateCode( - prompt = userRequest, - context = null, - language = "kotlin", - projectStructure = null - ) ?: Result.failure(Exception("No agent initialized")) - - result.fold( - onSuccess = { response -> - - if (response.contains("FILE_TO_MODIFY:")) { - callback.onProcessing("Modifying files...") - val modifications = processModifications(response, previousFileStates, callback) - - if (modifications.isNotEmpty()) { - val allSuccessful = modifications.all { it.writeResult is FileWriteResult.Success } - - if (allSuccessful) { - val results = modifications.map { mod -> - val isNewFile = !previousFileStates.containsKey(mod.filePath) - ModificationResult( - filePath = mod.filePath, - content = mod.content, - success = true, - message = "Modified successfully", - isNewFile = isNewFile - ) - } - - val summary = createSummary(results) - callback.onSuccess(response, results, summary) - success = true - } else { - callback.onProcessing("Some files failed. Retrying...") - currentAgent?.incrementAttemptCount() - delay(1500) - } - } else { - callback.onProcessing("No files were modified. Retrying...") - currentAgent?.incrementAttemptCount() - delay(1500) + val response = finalResponse ?: "Tool iteration limit reached. Please try a more specific request." + if (response.contains("FILE_TO_MODIFY:")) { + callback.onProcessing("Modifying files...") + val modifications = processModifications(response, previousFileStates, callback) + if (modifications.isNotEmpty()) { + val allSuccessful = modifications.all { it.writeResult is FileWriteResult.Success } + if (allSuccessful) { + val results = modifications.map { mod -> + val isNewFile = !previousFileStates.containsKey(mod.filePath) + ModificationResult(mod.filePath, mod.content, true, "Modified successfully", isNewFile) } + callback.onSuccess(response, results, createSummary(results)) + success = true } else { - val summary = ModificationSummary(0, 0, 0, 0, 0, emptyList()) - callback.onTextResponse(response, summary) + callback.onProcessing("Some files failed. Retrying...") + currentAgent?.incrementAttemptCount() + delay(1500) + } + } else { + callback.onProcessing("No files were modified. Retrying...") + currentAgent?.incrementAttemptCount() + delay(1500) + } + } else { + callback.onTextResponse(response, ModificationSummary(0, 0, 0, 0, 0, emptyList())) + success = true + } + } catch (error: Exception) { + android.util.Log.e("AIAgentManager", "Error occurred: ${error.message}", error) + val shouldSwitchProvider = error is com.tom.rv2ide.artificial.exceptions.RateLimitException || + error is com.tom.rv2ide.artificial.exceptions.QuotaExceededException || + error is com.tom.rv2ide.artificial.exceptions.InsufficientBalanceException || + error is com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException + if (shouldSwitchProvider && !providerSwitched) { + if (providerSwitchDialog.isAutoSwitchEnabled()) { + val alternativeProvider = getAlternativeProvider() + if (alternativeProvider != null && setProvider(alternativeProvider)) { + providerSwitched = true + currentAgent?.resetAttemptCount() + callback.onProcessing("āœ… Switched to ${currentAgent?.providerName ?: "Unknown"}") + } else { + callback.onError("${formatErrorMessage(error)}\n\nāŒ Failed to switch providers.") success = true } - }, - onFailure = { error -> - android.util.Log.e("AIAgentManager", "Error occurred: ${error.message}", error) - - val shouldSwitchProvider = error is com.tom.rv2ide.artificial.exceptions.RateLimitException || - error is com.tom.rv2ide.artificial.exceptions.QuotaExceededException || - error is com.tom.rv2ide.artificial.exceptions.InsufficientBalanceException || - error is com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException - - if (shouldSwitchProvider && !providerSwitched) { - val currentProviderName = currentAgent?.providerName ?: "Unknown" - val errorMsg = error.message ?: "Unknown error" - - if (providerSwitchDialog.isAutoSwitchEnabled()) { - val alternativeProvider = getAlternativeProvider() - if (alternativeProvider != null) { - callback.onProcessing("āš ļø $currentProviderName: $errorMsg") - callback.onProcessing("šŸ”„ Auto-switching to another provider...") - delay(1500) - - if (setProvider(alternativeProvider)) { - providerSwitched = true - currentAgent?.resetAttemptCount() - - val newProviderName = currentAgent?.providerName ?: "Unknown" - callback.onProcessing("āœ… Switched to $newProviderName") - } else { - val errorDisplay = formatErrorMessage(error) - callback.onError("$errorDisplay\n\nāŒ Failed to switch providers.") - success = true - } - } else { - val errorDisplay = formatErrorMessage(error) - callback.onError("$errorDisplay\n\nāŒ No alternative providers available.") - success = true - } - } else { - val errorDisplay = formatErrorMessage(error) - callback.onError("PROVIDER_SWITCH_REQUIRED::$errorDisplay") - success = true - } - } else if ((currentAgent?.canRetry() == true) && !providerSwitched) { - callback.onRetry( - currentAgent?.getCurrentAttemptCount() ?: 0, - "Error: ${error.message?.take(50) ?: "Unknown error"}. Retrying..." - ) - currentAgent?.incrementAttemptCount() - delay(1500) - } else { - val errorDisplay = formatErrorMessage(error) - callback.onError(errorDisplay) - success = true - } - } - ) - } catch (e: Exception) { - android.util.Log.e("AIAgentManager", "Exception occurred: ${e.message}", e) - - if (currentAgent?.canRetry() == true) { - callback.onRetry( - currentAgent?.getCurrentAttemptCount() ?: 0, - "Exception: ${e.message?.take(50) ?: "Unknown"}. Trying again..." - ) + } else { + callback.onError("PROVIDER_SWITCH_REQUIRED::${formatErrorMessage(error)}") + success = true + } + } else if ((currentAgent?.canRetry() == true) && !providerSwitched) { + callback.onRetry(currentAgent?.getCurrentAttemptCount() ?: 0, "Error: ${error.message?.take(50) ?: "Unknown error"}. Retrying...") currentAgent?.incrementAttemptCount() delay(1500) } else { - val errorDisplay = formatErrorMessage(e) - callback.onError(errorDisplay) + callback.onError(formatErrorMessage(error)) success = true } } } if (!success) { - val attemptCount = currentAgent?.getCurrentAttemptCount() ?: 0 - val agentName = currentAgent?.providerName ?: "No agent initialized" - callback.onError("Failed after $attemptCount attempts with $agentName.\n\nPlease check your API key and try again.") - undoLastModification() + callback.onError("Failed after ${currentAgent?.getCurrentAttemptCount() ?: 0} attempts with ${currentAgent?.providerName ?: "No agent initialized"}.\n\nPlease check your configuration and try again.") + undoLastModification() } } - private fun getAlternativeProvider(): String? { - val availableProviders = AIAgentRegistry.getAvailableProviders() - return availableProviders.firstOrNull { it != currentProviderId } - } + private fun getAlternativeProvider(): String? = AIAgentRegistry.getAvailableProviders().firstOrNull { it != currentProviderId } private suspend fun processModifications( response: String, @@ -295,215 +250,102 @@ class AIAgentManager(private val context: Context) { ): List { val modifications = mutableListOf() val parser = SnippetParser() - if (response.contains("FILE_TO_MODIFY:")) { val lines = response.lines() var currentFile: String? = null val contentBuilder = StringBuilder() var inContent = false - + fun flush(file: String?, content: StringBuilder) { + if (file != null && content.isNotEmpty()) { + val fileName = File(file).name + callback.onFileModifying(file, fileName) + val cleanedContent = parser.cleanFileContent(content.toString().trim()) + val previousContent = previousFileStates[file] + val writeResult = currentAgent?.writeFile(file, cleanedContent) ?: FileWriteResult.Error("No agent initialized") + val ok = writeResult is FileWriteResult.Success + currentAgent?.recordModification(file, previousContent, cleanedContent, ok) + callback.onFileModified(file, fileName, ok) + modifications.add(BaseFileModification(file, cleanedContent, writeResult)) + } + } for (line in lines) { if (line.startsWith("FILE_TO_MODIFY:")) { - if (currentFile != null && contentBuilder.isNotEmpty()) { - val fileName = File(currentFile).name - callback.onFileModifying(currentFile, fileName) - - val rawContent = contentBuilder.toString().trim() - val cleanedContent = parser.cleanFileContent(rawContent) - val previousContent = previousFileStates[currentFile] - - val writeResult = currentAgent?.writeFile(currentFile, cleanedContent) - ?: FileWriteResult.Error("No agent initialized") - - val success = writeResult is FileWriteResult.Success - currentAgent?.recordModification(currentFile, previousContent, cleanedContent, success) - - callback.onFileModified(currentFile, fileName, success) - delay(300) - - modifications.add(BaseFileModification(currentFile, cleanedContent, writeResult)) - } - + flush(currentFile, contentBuilder) currentFile = line.substringAfter("FILE_TO_MODIFY:").trim() contentBuilder.clear() inContent = true - } else if (inContent) { - contentBuilder.append(line).append("\n") - } - } - - if (currentFile != null && contentBuilder.isNotEmpty()) { - val fileName = File(currentFile).name - callback.onFileModifying(currentFile, fileName) - - val rawContent = contentBuilder.toString().trim() - val cleanedContent = parser.cleanFileContent(rawContent) - val previousContent = previousFileStates[currentFile] - - val writeResult = currentAgent?.writeFile(currentFile, cleanedContent) - ?: FileWriteResult.Error("No agent initialized") - - val success = writeResult is FileWriteResult.Success - currentAgent?.recordModification(currentFile, previousContent, cleanedContent, success) - - callback.onFileModified(currentFile, fileName, success) - delay(300) - - modifications.add(BaseFileModification(currentFile, cleanedContent, writeResult)) + } else if (inContent) contentBuilder.append(line).append("\n") } + flush(currentFile, contentBuilder) } - return modifications } private fun formatErrorMessage(error: Throwable): String { val errorMessage = error.message ?: "Unknown error occurred" - val stackTrace = error.stackTraceToString().take(500) val providerName = currentAgent?.providerName ?: "Unknown" - return when (error) { - is com.tom.rv2ide.artificial.exceptions.RateLimitException -> - "āš ļø RATE LIMIT EXCEEDED\n\nThe API rate limit has been exceeded.\nPlease wait a few minutes before trying again.\n\nDetails: $errorMessage" - is com.tom.rv2ide.artificial.exceptions.QuotaExceededException -> - "āš ļø QUOTA EXCEEDED\n\nYour API quota has been exhausted.\nPlease check your billing or upgrade your plan.\n\nDetails: $errorMessage" - is com.tom.rv2ide.artificial.exceptions.InsufficientBalanceException -> - "šŸ’³ INSUFFICIENT BALANCE\n\nYour account balance is too low to process this request.\nPlease add credits or upgrade your plan.\n\nProvider: $providerName\n\nDetails: $errorMessage" - is com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException -> - "āŒ INVALID API KEY\n\nThe API key is invalid or expired.\nPlease update your API key in the configuration.\n\nDetails: $errorMessage" - is java.net.UnknownHostException -> - "🌐 NETWORK ERROR\n\nCould not connect to the API server.\nPlease check your internet connection.\n\nDetails: $errorMessage" - is java.net.SocketTimeoutException -> - "ā±ļø TIMEOUT ERROR\n\nThe request took too long to complete.\nPlease try again.\n\nDetails: $errorMessage" - is org.json.JSONException -> - "šŸ“„ JSON PARSING ERROR\n\nFailed to parse API response.\nThe API may be experiencing issues.\n\nDetails: $errorMessage" - else -> - "āŒ ERROR OCCURRED\n\nProvider: $providerName\nError Type: ${error.javaClass.simpleName}\n\nMessage: $errorMessage\n\nStack Trace (first 500 chars):\n$stackTrace" + is com.tom.rv2ide.artificial.exceptions.RateLimitException -> "āš ļø RATE LIMIT EXCEEDED\n\n$errorMessage" + is com.tom.rv2ide.artificial.exceptions.QuotaExceededException -> "āš ļø QUOTA EXCEEDED\n\n$errorMessage" + is com.tom.rv2ide.artificial.exceptions.InsufficientBalanceException -> "šŸ’³ INSUFFICIENT BALANCE\n\nProvider: $providerName\n\n$errorMessage" + is com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException -> "āŒ INVALID API KEY\n\n$errorMessage" + is java.net.UnknownHostException -> "🌐 NETWORK ERROR\n\n$errorMessage" + is java.net.SocketTimeoutException -> "ā±ļø TIMEOUT ERROR\n\n$errorMessage" + is org.json.JSONException -> "šŸ“„ JSON PARSING ERROR\n\n$errorMessage" + else -> "āŒ ERROR OCCURRED\n\nProvider: $providerName\nError: ${error.javaClass.simpleName}\n\n$errorMessage" } } - fun showProviderErrorDialogFromFragment( - activity: android.app.Activity, - errorMessage: String, - onProviderSelected: (String) -> Unit - ) { + fun showProviderErrorDialogFromFragment(activity: android.app.Activity, errorMessage: String, onProviderSelected: (String) -> Unit) { val currentProviderName = currentAgent?.providerName ?: "Unknown" - val availableProviders = getAvailableProviders() - .filter { it.id != currentProviderId && it.isAvailable } - .map { Pair(it.id, it.name) } - - providerSwitchDialog.showProviderErrorDialog( - currentProviderName, - errorMessage, - availableProviders, + val availableProviders = getAvailableProviders().filter { it.id != currentProviderId && it.isAvailable }.map { Pair(it.id, it.name) } + providerSwitchDialog.showProviderErrorDialog(currentProviderName, errorMessage, availableProviders, onProviderSelected = { providerId -> setProvider(providerId) val agents = Agents(context) val availableModels = agents.getModelsForProvider(providerId) - if (availableModels.isNotEmpty()) { - agents.setAgent(availableModels[0]) - } + if (availableModels.isNotEmpty()) agents.setAgent(availableModels[0]) reinitializeWithSelectedModel() onProviderSelected(providerId) }, onEnableAutoSwitch = { - val alternativeProvider = getAlternativeProvider() - if (alternativeProvider != null) { - setProvider(alternativeProvider) + getAlternativeProvider()?.let { + setProvider(it) val agents = Agents(context) - val availableModels = agents.getModelsForProvider(alternativeProvider) - if (availableModels.isNotEmpty()) { - agents.setAgent(availableModels[0]) - } + val availableModels = agents.getModelsForProvider(it) + if (availableModels.isNotEmpty()) agents.setAgent(availableModels[0]) reinitializeWithSelectedModel() } } ) } - fun isAutoSwitchEnabled(): Boolean { - return providerSwitchDialog.isAutoSwitchEnabled() - } - - fun setAutoSwitch(enabled: Boolean) { - providerSwitchDialog.setAutoSwitch(enabled) - } + fun isAutoSwitchEnabled(): Boolean = providerSwitchDialog.isAutoSwitchEnabled() + fun setAutoSwitch(enabled: Boolean) = providerSwitchDialog.setAutoSwitch(enabled) private fun createSummary(results: List): ModificationSummary { val successful = results.count { it.success } val failed = results.count { !it.success } val newFiles = results.count { it.isNewFile } val modifiedFiles = results.count { !it.isNewFile } - - val fileDetails = results.map { result -> - FileDetail( - fileName = File(result.filePath).name, - filePath = result.filePath, - status = if (result.success) FileStatus.SUCCESS else FileStatus.FAILED, - changeType = if (result.isNewFile) ChangeType.CREATED else ChangeType.MODIFIED - ) - } - - return ModificationSummary( - totalFiles = results.size, - successfulFiles = successful, - failedFiles = failed, - newFiles = newFiles, - modifiedFiles = modifiedFiles, - fileDetails = fileDetails - ) + val fileDetails = results.map { FileDetail(File(it.filePath).name, it.filePath, if (it.success) FileStatus.SUCCESS else FileStatus.FAILED, if (it.isNewFile) ChangeType.CREATED else ChangeType.MODIFIED) } + return ModificationSummary(results.size, successful, failed, newFiles, modifiedFiles, fileDetails) } private fun captureCurrentFileStates(): Map { val states = mutableMapOf() val projectRoot = currentProjectRoot ?: return states - if (!projectRoot.exists()) return states - - projectRoot.walkTopDown() - .filter { it.isFile } - .filter { - it.extension in listOf("kt", "java", "xml", "gradle", "kts") && - !it.path.contains("/build/") && - !it.path.contains("/.gradle/") - } - .forEach { file -> - try { - states[file.absolutePath] = file.readText() - } catch (e: Exception) { - } - } - - return states - } - - fun undoLastModification(): Boolean { - return currentAgent?.undoLastModification() ?: false - } - - fun reinitializeWithSelectedModel() { - val factory = AIAgentRegistry.getFactory(currentProviderId) - factory?.getApiKey()?.let { apiKey -> - currentAgent?.reinitializeWithNewModel(apiKey, context) + projectRoot.walkTopDown().filter { it.isFile }.filter { it.extension in listOf("kt","java","xml","gradle","kts") && !it.path.contains("/build/") && !it.path.contains("/.gradle/") }.forEach { file -> + try { states[file.absolutePath] = file.readText() } catch (_: Exception) {} } + return states } - fun getCurrentModelName(): String { - val agents = Agents(context) - return agents.getAgent() - } - - fun getConversationHistory(): List { - return currentAgent?.getModificationHistory()?.map { - UnifiedModificationAttempt( - timestamp = it.timestamp, - filePath = it.filePath, - previousContent = it.previousContent, - newContent = it.newContent, - attemptNumber = it.attemptNumber, - success = it.success - ) - } ?: emptyList() - } + fun undoLastModification(): Boolean = currentAgent?.undoLastModification() ?: false + fun reinitializeWithSelectedModel() { AIAgentRegistry.getFactory(currentProviderId)?.getApiKey()?.let { currentAgent?.reinitializeWithNewModel(it, context) } } + fun getCurrentModelName(): String = Agents(context).getAgent() + fun getConversationHistory(): List = currentAgent?.getModificationHistory()?.map { UnifiedModificationAttempt(it.timestamp, it.filePath, it.previousContent, it.newContent, it.attemptNumber, it.success) } ?: emptyList() interface AIAgentCallback { fun onProcessing(message: String) @@ -515,51 +357,13 @@ class AIAgentManager(private val context: Context) { fun onRetry(attemptNumber: Int, message: String) } - data class ModificationResult( - val filePath: String, - val content: String, - val success: Boolean, - val message: String, - val isNewFile: Boolean = false - ) - - data class ModificationSummary( - val totalFiles: Int, - val successfulFiles: Int, - val failedFiles: Int, - val newFiles: Int, - val modifiedFiles: Int, - val fileDetails: List - ) - - data class FileDetail( - val fileName: String, - val filePath: String, - val status: FileStatus, - val changeType: ChangeType - ) - + data class ModificationResult(val filePath: String, val content: String, val success: Boolean, val message: String, val isNewFile: Boolean = false) + data class ModificationSummary(val totalFiles: Int, val successfulFiles: Int, val failedFiles: Int, val newFiles: Int, val modifiedFiles: Int, val fileDetails: List) + data class FileDetail(val fileName: String, val filePath: String, val status: FileStatus, val changeType: ChangeType) enum class FileStatus { SUCCESS, FAILED } enum class ChangeType { CREATED, MODIFIED } - - data class ProviderInfo( - val id: String, - val name: String, - val isAvailable: Boolean - ) + data class ProviderInfo(val id: String, val name: String, val isAvailable: Boolean) } -data class BaseFileModification( - val filePath: String, - val content: String, - val writeResult: FileWriteResult -) - -data class UnifiedModificationAttempt( - val timestamp: Long, - val filePath: String, - val previousContent: String?, - val newContent: String, - val attemptNumber: Int = 0, - val success: Boolean = false -) \ No newline at end of file +data class BaseFileModification(val filePath: String, val content: String, val writeResult: FileWriteResult) +data class UnifiedModificationAttempt(val timestamp: Long, val filePath: String, val previousContent: String?, val newContent: String, val attemptNumber: Int = 0, val success: Boolean = false) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt index b6f91a6730..9aed9e0055 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt @@ -18,15 +18,20 @@ package com.tom.rv2ide.artificial.agents.custom import android.content.Context -import com.tom.rv2ide.app.BaseApplication import com.tom.rv2ide.artificial.agents.AIAgent import com.tom.rv2ide.artificial.agents.AIAgentRegistry import com.tom.rv2ide.artificial.agents.Agents import com.tom.rv2ide.artificial.agents.ModificationAttempt import com.tom.rv2ide.artificial.file.AIFileWriter import com.tom.rv2ide.artificial.file.FileWriteResult +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.profiles.CustomProviderProfileStore import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult import com.tom.rv2ide.artificial.rules.WritingRules +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor import java.io.File import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers @@ -39,8 +44,7 @@ import org.json.JSONArray import org.json.JSONObject /* - * Custom OpenAI-compatible provider support. - * Configure base URL, model name, and optional API key in AI settings. + * Custom OpenAI-compatible provider support with profiles, tools, and MCP. */ class CustomProvider : AIAgent { @@ -61,34 +65,23 @@ class CustomProvider : AIAgent { private var currentAttemptCount = 0 private val maxRetryAttempts = 3 private var agents: Agents? = null + private var appContext: Context? = null override val providerId = "custom" override val providerName = "Custom Provider" companion object { - private const val PREF_BASE_URL = "ai_agent_custom_base_url" - private const val PREF_MODEL_NAME = "ai_agent_custom_model_name" - private const val PREF_API_KEY = "ai_agent_custom_api_key" - fun registerAgent() { AIAgentRegistry.register( "custom", object : AIAgentRegistry.AgentFactory { - override fun create(context: Context): AIAgent { - return CustomProvider() - } - + override fun create(context: Context): AIAgent = CustomProvider() override fun hasValidApiKey(): Boolean { - val prefs = BaseApplication.getBaseInstance().prefManager - val url = prefs.getString(PREF_BASE_URL, null) - val model = prefs.getString(PREF_MODEL_NAME, null) - android.util.Log.d("CustomProvider", "hasValidApiKey check: url=$url, model=$model") - return !url.isNullOrEmpty() && !model.isNullOrEmpty() + val p = CustomProviderProfileStore.getActive() + return p != null && p.baseUrl.isNotBlank() && p.modelName.isNotBlank() } - override fun getApiKey(): String? { - val prefs = BaseApplication.getBaseInstance().prefManager - val key = prefs.getString(PREF_API_KEY, null) - return if (key.isNullOrBlank()) "custom" else key + val p = CustomProviderProfileStore.getActive() ?: return null + return if (p.apiKey.isBlank()) "custom" else p.apiKey } }) } @@ -104,295 +97,151 @@ class CustomProvider : AIAgent { } override fun initialize(apiKey: String, context: Context) { - try { - agents = Agents(context) - val prefs = BaseApplication.getBaseInstance().prefManager - baseUrl = prefs.getString(PREF_BASE_URL, null) - modelName = prefs.getString(PREF_MODEL_NAME, null) - this.apiKey = prefs.getString(PREF_API_KEY, null) - - android.util.Log.d( - "CustomProvider", "Initialized with baseUrl=$baseUrl, model=$modelName, hasKey=${!this.apiKey.isNullOrBlank()}") - - if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) { - throw IllegalStateException( - "Custom provider not configured. Please set base URL and model name in AI settings.") - } - } catch (e: Exception) { - throw e + appContext = context.applicationContext + agents = Agents(context) + val profile = CustomProviderProfileStore.getActive() + baseUrl = profile?.baseUrl + modelName = profile?.modelName + this.apiKey = profile?.apiKey + if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) { + throw IllegalStateException("Custom provider not configured. Please add/select a custom provider profile.") } } - override fun isInitialized(): Boolean = - !baseUrl.isNullOrEmpty() && !modelName.isNullOrEmpty() - - override fun reinitializeWithNewModel(apiKey: String, context: Context) { - initialize(apiKey, context) - } + override fun isInitialized(): Boolean = !baseUrl.isNullOrEmpty() && !modelName.isNullOrEmpty() - override fun setContext(context: Context) { - fileWriter = AIFileWriter(context) - } + override fun reinitializeWithNewModel(apiKey: String, context: Context) { initialize(apiKey, context) } + override fun setContext(context: Context) { appContext = context.applicationContext; fileWriter = AIFileWriter(context) } + override fun setProjectData(projectTreeResult: ProjectTreeResult) { this.projectTreeResult = projectTreeResult } + override fun clearConversation() { conversationHistory.clear(); modificationHistory.clear(); currentAttemptCount = 0 } - override fun setProjectData(projectTreeResult: ProjectTreeResult) { - this.projectTreeResult = projectTreeResult - } - - override fun clearConversation() { - conversationHistory.clear() - modificationHistory.clear() - currentAttemptCount = 0 - } - - override fun recordModification( - filePath: String, - oldContent: String?, - newContent: String, - success: Boolean - ) { - modificationHistory.add( - ModificationAttempt( - timestamp = System.currentTimeMillis(), - filePath = filePath, - previousContent = oldContent, - newContent = newContent, - attemptNumber = currentAttemptCount, - success = success)) + override fun recordModification(filePath: String, oldContent: String?, newContent: String, success: Boolean) { + modificationHistory.add(ModificationAttempt(System.currentTimeMillis(), filePath, oldContent, newContent, currentAttemptCount, success)) } override fun undoLastModification(): Boolean { if (modificationHistory.isEmpty()) return false - val lastMod = modificationHistory.lastOrNull { it.success } ?: return false - if (lastMod.previousContent != null) { val result = writeFile(lastMod.filePath, lastMod.previousContent) - if (result is FileWriteResult.Success) { - modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)) - return true - } + if (result is FileWriteResult.Success) { modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)); return true } } else { - try { - File(lastMod.filePath).delete() - modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)) - return true - } catch (e: Exception) { - return false - } + try { File(lastMod.filePath).delete(); modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)); return true } catch (_: Exception) {} } return false } - override fun getModificationHistory(): List { - return modificationHistory.toList() - } - - override fun resetAttemptCount() { - currentAttemptCount = 0 - } - - override fun incrementAttemptCount() { - currentAttemptCount++ - } - + override fun getModificationHistory(): List = modificationHistory.toList() + override fun resetAttemptCount() { currentAttemptCount = 0 } + override fun incrementAttemptCount() { currentAttemptCount++ } override fun getCurrentAttemptCount(): Int = currentAttemptCount - override fun canRetry(): Boolean = currentAttemptCount < maxRetryAttempts private fun isUserRequestingCorrection(message: String): Boolean { - val correctionKeywords = - listOf( - "wrong", - "not what", - "mistake", - "error", - "incorrect", - "that's not", - "not right", - "fix", - "undo", - "revert", - "different", - "try again", - "not working") + val correctionKeywords = listOf("wrong","not what","mistake","error","incorrect","that's not","not right","fix","undo","revert","different","try again","not working") return correctionKeywords.any { message.lowercase().contains(it) } } + private fun composeSystemPrompt(context: Context): String { + val base = writingRules.useThis() + val agentProfile = AgentPromptProfileStore.getActive() + val sb = StringBuilder(base) + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(context, projectTreeResult, null, agentProfile?.allowCommandExecution == true) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { + try { + val servers = McpServerStore.load().filter { s -> + agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) + } + McpToolExecutor(servers).definitions() + } catch (_: Exception) { emptyList() } + } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + override suspend fun generateCode( prompt: String, context: String?, language: String, projectStructure: String?, - ): Result = - withContext(Dispatchers.IO) { - try { - if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) { - return@withContext Result.failure(IllegalStateException("Custom provider not configured")) - } - - val fileContents = readRelevantFiles() - val needsCorrection = isUserRequestingCorrection(prompt) - - val fullPrompt = buildString { - append("=== PROJECT STRUCTURE (THESE ARE THE EXACT PATHS YOU MUST USE) ===\n") - if (projectTreeResult != null) { - append(projectTreeResult!!.tree) - append("\n\n") - append( - "CRITICAL: Use ONLY the paths shown above. Do NOT make up fake paths like '/storage/emulated/0/project' or 'com.example.yourproject'.\n") - append("CRITICAL: Look at the actual paths above and use those EXACT paths.\n\n") - } - - if (fileContents.isNotEmpty()) { - append("=== CURRENT FILES CONTENT ===\n") - fileContents.forEach { (path, content) -> - append("FILE: $path\n") - append("CONTENT:\n") - append(content) - append("\n\n") - } - } - - if (context != null) { - append("=== ADDITIONAL CONTEXT ===\n") - append(context) - append("\n\n") - } - - if (conversationHistory.isNotEmpty()) { - append("=== CONVERSATION HISTORY ===\n") - conversationHistory.forEach { msg -> append("${msg.role.uppercase()}: ${msg.content}\n\n") } - } - - if (needsCorrection && modificationHistory.isNotEmpty()) { - append("=== CORRECTION REQUIRED ===\n") - append("The user indicated the previous modification was WRONG.\n") - append("Previous failed attempts:\n") - modificationHistory.takeLast(3).forEach { attempt -> - append("Attempt ${attempt.attemptNumber}: ${attempt.filePath}\n") - append("Result: ${if (attempt.success) "Applied but user rejected" else "Failed"}\n\n") - } - append("You MUST try a DIFFERENT approach. Do NOT repeat the same solution.\n") - append("Analyze what went wrong and provide a better solution.\n\n") - } - - if (currentAttemptCount > 0) { - append("=== RETRY ATTEMPT $currentAttemptCount/$maxRetryAttempts ===\n") - append("This is retry attempt number $currentAttemptCount.\n") - append("Previous attempts did not satisfy the user.\n") - append("Think carefully and provide a different solution.\n\n") - } - - append("=== USER REQUEST ===\n") - append(prompt) - } - - val messages = JSONArray() - messages.put( - JSONObject().apply { - put("role", "system") - put("content", writingRules.useThis()) - }) - messages.put( - JSONObject().apply { - put("role", "user") - put("content", fullPrompt) - }) - - val requestBody = - JSONObject().apply { - put("model", modelName) - put("messages", messages) - put("temperature", 0.7) - put("stream", false) - } - - val endpoint = buildEndpoint(baseUrl!!) - val requestBuilder = - Request.Builder() - .url(endpoint) - .post(requestBody.toString().toRequestBody("application/json".toMediaType())) - .addHeader("Content-Type", "application/json") - - val key = apiKey?.trim() - if (!key.isNullOrEmpty()) { - requestBuilder.addHeader("Authorization", "Bearer $key") - } - - val response = - try { - httpClient.newCall(requestBuilder.build()).execute() - } catch (e: Exception) { - val errorMessage = e.message ?: "" - when { - errorMessage.contains("timeout") || errorMessage.contains("connect") -> - throw com.tom.rv2ide.artificial.exceptions.RateLimitException( - "Connection timeout. Please check your custom provider endpoint.") - else -> throw e - } - } - - if (!response.isSuccessful) { - val errorBody = response.body?.string() ?: "Unknown error" - return@withContext Result.failure( - Exception("Custom provider error ${response.code}: $errorBody")) - } - - val responseBody = response.body?.string() ?: "" - val jsonResponse = JSONObject(responseBody) - - val generatedResponse = - jsonResponse.getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content") - - if (generatedResponse.isBlank()) { - return@withContext Result.failure(Exception("Empty response from custom provider")) - } + ): Result = withContext(Dispatchers.IO) { + try { + if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) return@withContext Result.failure(IllegalStateException("Custom provider not configured")) + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + val fileContents = readRelevantFiles() + val needsCorrection = isUserRequestingCorrection(prompt) + val fullPrompt = buildString { + append("=== PROJECT STRUCTURE (THESE ARE THE EXACT PATHS YOU MUST USE) ===\n") + if (projectTreeResult != null) { + append(projectTreeResult!!.tree) + append("\n\nCRITICAL: Use ONLY the paths shown above. Do NOT make up fake paths.\nCRITICAL: Look at the actual paths above and use those EXACT paths.\n\n") + } + if (fileContents.isNotEmpty()) { + append("=== CURRENT FILES CONTENT ===\n") + fileContents.forEach { (path, content) -> append("FILE: $path\nCONTENT:\n").append(content).append("\n\n") } + } + if (context != null) append("=== ADDITIONAL CONTEXT ===\n").append(context).append("\n\n") + if (conversationHistory.isNotEmpty()) { + append("=== CONVERSATION HISTORY ===\n") + conversationHistory.takeLast(12).forEach { msg -> append("${msg.role.uppercase()}: ${msg.content.take(4000)}\n\n") } + } + if (needsCorrection && modificationHistory.isNotEmpty()) { + append("=== CORRECTION REQUIRED ===\nPrevious failed attempts:\n") + modificationHistory.takeLast(3).forEach { attempt -> append("Attempt ${attempt.attemptNumber}: ${attempt.filePath} - ${if (attempt.success) "Applied but user rejected" else "Failed"}\n") } + append("Provide a COMPLETELY DIFFERENT approach.\n\n") + } + if (currentAttemptCount > 0) append("=== RETRY ATTEMPT $currentAttemptCount/$maxRetryAttempts ===\nTry a different solution.\n\n") + append("=== USER REQUEST ===\n").append(prompt) + } - conversationHistory.add(ConversationMessage("user", prompt)) - conversationHistory.add(ConversationMessage("assistant", generatedResponse)) + val messages = JSONArray() + messages.put(JSONObject().apply { put("role", "system"); put("content", composeSystemPrompt(ctx)) }) + messages.put(JSONObject().apply { put("role", "user"); put("content", fullPrompt) }) - if (conversationHistory.size > 20) { - conversationHistory.removeAt(0) - conversationHistory.removeAt(0) - } + val requestBody = JSONObject().apply { put("model", modelName); put("messages", messages); put("temperature", 0.7); put("stream", false) } + val endpoint = buildEndpoint(baseUrl!!) + val requestBuilder = Request.Builder().url(endpoint).post(requestBody.toString().toRequestBody("application/json".toMediaType())).addHeader("Content-Type", "application/json") + val key = apiKey?.trim() + if (!key.isNullOrEmpty()) requestBuilder.addHeader("Authorization", "Bearer $key") - Result.success(generatedResponse) - } catch (e: com.tom.rv2ide.artificial.exceptions.RateLimitException) { - Result.failure(e) - } catch (e: com.tom.rv2ide.artificial.exceptions.QuotaExceededException) { - Result.failure(e) - } catch (e: com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException) { - Result.failure(e) - } catch (e: Exception) { - Result.failure(e) - } + val response = try { httpClient.newCall(requestBuilder.build()).execute() } catch (e: Exception) { + val errorMessage = e.message ?: "" + if (errorMessage.contains("timeout") || errorMessage.contains("connect")) throw com.tom.rv2ide.artificial.exceptions.RateLimitException("Connection timeout. Please check your custom provider endpoint.") + throw e + } + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + return@withContext Result.failure(Exception("Custom provider error ${response.code}: $errorBody")) } + val responseBody = response.body?.string() ?: "" + val jsonResponse = JSONObject(responseBody) + val generatedResponse = jsonResponse.getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content") + if (generatedResponse.isBlank()) return@withContext Result.failure(Exception("Empty response from custom provider")) + + conversationHistory.add(ConversationMessage("user", prompt)) + conversationHistory.add(ConversationMessage("assistant", generatedResponse)) + while (conversationHistory.size > 20) { conversationHistory.removeAt(0) } + Result.success(generatedResponse) + } catch (e: com.tom.rv2ide.artificial.exceptions.RateLimitException) { Result.failure(e) + } catch (e: com.tom.rv2ide.artificial.exceptions.QuotaExceededException) { Result.failure(e) + } catch (e: com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException) { Result.failure(e) + } catch (e: Exception) { Result.failure(e) } + } private fun readRelevantFiles(): Map { val filesContent = mutableMapOf() val tree = projectTreeResult?.tree ?: return filesContent - - val filePaths = tree.lines().filter { it.isNotBlank() } - - filePaths.forEach { filePath -> + tree.lines().filter { it.isNotBlank() }.forEach { filePath -> val trimmedPath = filePath.trim() val file = File(trimmedPath) - - if (file.isFile && - (trimmedPath.endsWith(".kt") || - trimmedPath.endsWith(".java") || - trimmedPath.endsWith(".xml") || - trimmedPath.endsWith(".gradle") || - trimmedPath.endsWith(".gradle.kts")) && - !trimmedPath.contains("/build/") && - !trimmedPath.contains("/.gradle/")) { - try { - val content = file.readText() - filesContent[trimmedPath] = content - } catch (e: Exception) {} + if (file.isFile && (trimmedPath.endsWith(".kt") || trimmedPath.endsWith(".java") || trimmedPath.endsWith(".xml") || trimmedPath.endsWith(".gradle") || trimmedPath.endsWith(".gradle.kts")) && !trimmedPath.contains("/build/") && !trimmedPath.contains("/.gradle/")) { + try { filesContent[trimmedPath] = file.readText() } catch (_: Exception) {} } } - return filesContent } @@ -400,63 +249,6 @@ class CustomProvider : AIAgent { val writer = fileWriter ?: return FileWriteResult.Error("File writer not initialized") return writer.writeFile(filePath, content, createBackup = true) } - - fun parseAndApplyModifications( - response: String, - capturedStates: Map - ): List { - val modifications = mutableListOf() - val parser = com.tom.rv2ide.artificial.parser.SnippetParser() - - if (response.contains("FILE_TO_MODIFY:")) { - val lines = response.lines() - var currentFile: String? = null - val contentBuilder = StringBuilder() - var inContent = false - - for (line in lines) { - if (line.startsWith("FILE_TO_MODIFY:")) { - if (currentFile != null && contentBuilder.isNotEmpty()) { - val rawContent = contentBuilder.toString().trim() - val cleanedContent = parser.cleanFileContent(rawContent) - val previousContent = capturedStates[currentFile] - val writeResult = writeFile(currentFile, cleanedContent) - - val success = writeResult is FileWriteResult.Success - recordModification(currentFile, previousContent, cleanedContent, success) - - modifications.add(FileModification(currentFile, cleanedContent, writeResult)) - } - - currentFile = line.substringAfter("FILE_TO_MODIFY:").trim() - contentBuilder.clear() - inContent = true - } else if (inContent) { - contentBuilder.append(line).append("\n") - } - } - - if (currentFile != null && contentBuilder.isNotEmpty()) { - val rawContent = contentBuilder.toString().trim() - val cleanedContent = parser.cleanFileContent(rawContent) - val previousContent = capturedStates[currentFile] - val writeResult = writeFile(currentFile, cleanedContent) - - val success = writeResult is FileWriteResult.Success - recordModification(currentFile, previousContent, cleanedContent, success) - - modifications.add(FileModification(currentFile, cleanedContent, writeResult)) - } - } - - return modifications - } } data class ConversationMessage(val role: String, val content: String) - -data class FileModification( - val filePath: String, - val content: String, - val writeResult: FileWriteResult -) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/AgentProfilesDialog.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/AgentProfilesDialog.kt new file mode 100644 index 0000000000..8242edbe6e --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/AgentProfilesDialog.kt @@ -0,0 +1,91 @@ +package com.tom.rv2ide.artificial.dialogs + +import android.content.Context +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.Toast +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.materialswitch.MaterialSwitch +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.tom.rv2ide.artificial.profiles.AgentPromptProfile +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.profiles.SkillPresets + +object AgentProfilesDialog { + fun show(context: Context, onChanged: () -> Unit = {}) { + val (activeId, profiles) = AgentPromptProfileStore.load() + val names = profiles.map { "${it.name}${if (it.id == activeId) " (active)" else ""}" }.toTypedArray() + arrayOf("+ Create new profile", "+ Add prebuilt skill") + MaterialAlertDialogBuilder(context) + .setTitle("Agent Profiles & Skills") + .setItems(names) { d, which -> + d.dismiss() + when { + which < profiles.size -> selectOrEdit(context, profiles[which], onChanged) + which == profiles.size -> edit(context, null, onChanged) + else -> showPresets(context, onChanged) + } + } + .setNegativeButton("Close", null) + .show() + } + + private fun showPresets(context: Context, onChanged: () -> Unit) { + val presets = SkillPresets.all() + MaterialAlertDialogBuilder(context) + .setTitle("Prebuilt Skills") + .setItems(presets.map { it.name }.toTypedArray()) { d, which -> + d.dismiss() + AgentPromptProfileStore.addPreset(presets[which]) + Toast.makeText(context, "Skill added", Toast.LENGTH_SHORT).show() + onChanged(); show(context, onChanged) + } + .setNegativeButton("Back") { _, _ -> show(context, onChanged) } + .show() + } + + private fun selectOrEdit(context: Context, profile: AgentPromptProfile, onChanged: () -> Unit) { + MaterialAlertDialogBuilder(context) + .setTitle(profile.name) + .setItems(arrayOf("Activate", "Edit", "Duplicate", "Delete")) { d, which -> + d.dismiss() + when (which) { + 0 -> { AgentPromptProfileStore.setActive(profile.id); Toast.makeText(context, "Activated", Toast.LENGTH_SHORT).show(); onChanged() } + 1 -> edit(context, profile, onChanged) + 2 -> { AgentPromptProfileStore.upsert(profile.copy(id = java.util.UUID.randomUUID().toString(), name = profile.name + " (copy)")); onChanged(); show(context, onChanged) } + 3 -> { AgentPromptProfileStore.delete(profile.id); onChanged(); show(context, onChanged) } + } + } + .setNegativeButton("Back") { _, _ -> show(context, onChanged) } + .show() + } + + private fun edit(context: Context, existing: AgentPromptProfile?, onChanged: () -> Unit) { + val container = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL; setPadding(48, 24, 48, 0) } + val nameInput = TextInputEditText(context).apply { setText(existing?.name ?: "New Profile") } + val promptInput = TextInputEditText(context).apply { setText(existing?.systemPrompt ?: ""); minLines = 4; maxLines = 10; hint = "Extra system prompt / instructions" } + val toolsSwitch = MaterialSwitch(context).apply { text = "Enable built-in tools/skills"; isChecked = existing?.enableTools ?: true } + val mcpSwitch = MaterialSwitch(context).apply { text = "Enable MCP tools"; isChecked = existing?.enableMcpTools ?: true } + val cmdSwitch = MaterialSwitch(context).apply { text = "Allow command execution (risky, opt-in)"; isChecked = existing?.allowCommandExecution ?: false } + container.addView(TextInputLayout(context).apply { hint = "Profile name"; addView(nameInput) }) + container.addView(TextInputLayout(context).apply { hint = "System prompt"; addView(promptInput); setPadding(0, 12, 0, 0) }) + container.addView(toolsSwitch); container.addView(mcpSwitch); container.addView(cmdSwitch) + val scroll = ScrollView(context).apply { addView(container) } + MaterialAlertDialogBuilder(context) + .setTitle(if (existing == null) "Create Agent Profile" else "Edit Agent Profile") + .setView(scroll) + .setPositiveButton("Save") { _, _ -> + val p = (existing ?: AgentPromptProfile(name = "Profile")).copy( + name = nameInput.text?.toString()?.trim().orEmpty().ifBlank { "Profile" }, + systemPrompt = promptInput.text?.toString().orEmpty(), + enableTools = toolsSwitch.isChecked, + enableMcpTools = mcpSwitch.isChecked, + allowCommandExecution = cmdSwitch.isChecked, + ) + AgentPromptProfileStore.upsert(p, makeActive = existing == null) + onChanged(); show(context, onChanged) + } + .setNegativeButton("Cancel") { _, _ -> show(context, onChanged) } + .show() + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomProviderConfigDialog.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomProviderConfigDialog.kt index 97a1b90666..f785a8ad4f 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomProviderConfigDialog.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomProviderConfigDialog.kt @@ -1,72 +1,148 @@ package com.tom.rv2ide.artificial.dialogs +import android.app.AlertDialog import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Button +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import android.widget.LinearLayout +import android.widget.Toast import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout import com.tom.rv2ide.R -import com.tom.rv2ide.app.BaseApplication +import com.tom.rv2ide.artificial.profiles.CustomProviderProfile +import com.tom.rv2ide.artificial.profiles.CustomProviderProfileStore class CustomProviderConfigDialog( private val onSave: (baseUrl: String, modelName: String, apiKey: String) -> Unit ) : BottomSheetDialogFragment() { + private lateinit var profileSelectorLayout: TextInputLayout + private lateinit var profileSelector: AutoCompleteTextView + private lateinit var profileNameInput: TextInputEditText private lateinit var baseUrlInput: TextInputEditText private lateinit var modelNameInput: TextInputEditText private lateinit var apiKeyInput: TextInputEditText - private lateinit var saveButton: Button - private lateinit var cancelButton: Button + private lateinit var saveButton: MaterialButton + private lateinit var deleteButton: MaterialButton + private lateinit var cancelButton: MaterialButton - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { + private var profiles: MutableList = mutableListOf() + private var activeId: String? = null + private var selectedId: String? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.dialog_custom_provider_config, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - + profileSelectorLayout = view.findViewById(R.id.profileSelectorLayout) + profileSelector = view.findViewById(R.id.profileSelector) + profileNameInput = view.findViewById(R.id.profileNameInput) baseUrlInput = view.findViewById(R.id.baseUrlInput) modelNameInput = view.findViewById(R.id.modelNameInput) apiKeyInput = view.findViewById(R.id.apiKeyInput) saveButton = view.findViewById(R.id.saveButton) + deleteButton = view.findViewById(R.id.deleteButton) cancelButton = view.findViewById(R.id.cancelButton) - loadSavedConfig() + reload() + setupSelector() saveButton.setOnClickListener { + val name = profileNameInput.text.toString().trim().ifBlank { "Custom" } val baseUrl = baseUrlInput.text.toString().trim() val modelName = modelNameInput.text.toString().trim() val apiKey = apiKeyInput.text.toString().trim() + if (baseUrl.isEmpty() || modelName.isEmpty()) { + Toast.makeText(requireContext(), "Base URL and model name are required", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + val current = profiles.firstOrNull { it.id == selectedId } + val profile = (current ?: CustomProviderProfile(name = name, baseUrl = baseUrl, modelName = modelName, apiKey = apiKey)).copy( + name = name, baseUrl = baseUrl, modelName = modelName, apiKey = apiKey + ) + val saved = CustomProviderProfileStore.upsert(profile, makeActive = true) + selectedId = saved.id + reload() + setupSelector() + onSave(baseUrl, modelName, apiKey) + Toast.makeText(requireContext(), "Profile saved and activated", Toast.LENGTH_SHORT).show() + } - if (baseUrl.isNotEmpty() && modelName.isNotEmpty()) { - saveConfig(baseUrl, modelName, apiKey) - onSave(baseUrl, modelName, apiKey) - dismiss() + deleteButton.setOnClickListener { + val id = selectedId ?: return@setOnClickListener + if (profiles.size <= 1) { + Toast.makeText(requireContext(), "Keep at least one profile", Toast.LENGTH_SHORT).show() + return@setOnClickListener } + CustomProviderProfileStore.delete(id) + reload() + setupSelector() + } + + view.findViewById(R.id.newProfileButton)?.setOnClickListener { + selectedId = null + profileNameInput.setText("New Profile") + baseUrlInput.setText("https://api.openai.com") + modelNameInput.setText("gpt-4o-mini") + apiKeyInput.setText("") + profileSelector.setText("(New Profile)", false) } - cancelButton.setOnClickListener { - dismiss() + cancelButton.setOnClickListener { dismiss() } + } + + private fun reload() { + val (aid, list) = CustomProviderProfileStore.load() + activeId = aid + profiles = list.toMutableList() + if (profiles.isEmpty()) { + val def = CustomProviderProfile(name = "Default", baseUrl = "https://api.openai.com", modelName = "gpt-4o-mini") + CustomProviderProfileStore.save(def.id, listOf(def)) + profiles = mutableListOf(def) + activeId = def.id } + if (selectedId == null || profiles.none { it.id == selectedId }) selectedId = activeId ?: profiles.first().id + bindSelected() } - private fun loadSavedConfig() { - val prefs = BaseApplication.getBaseInstance().prefManager - baseUrlInput.setText(prefs.getString("ai_agent_custom_base_url", "https://api.openai.com")) - modelNameInput.setText(prefs.getString("ai_agent_custom_model_name", "gpt-4o-mini")) - apiKeyInput.setText(prefs.getString("ai_agent_custom_api_key", "")) + private fun setupSelector() { + val names = profiles.map { "${it.name}${if (it.id == activeId) " (active)" else ""}" } + listOf("+ New profile") + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, names) + profileSelector.setAdapter(adapter) + val idx = profiles.indexOfFirst { it.id == selectedId } + if (idx >= 0) profileSelector.setText(names[idx], false) + profileSelector.setOnItemClickListener { _, _, pos, _ -> + if (pos >= profiles.size) { + selectedId = null + profileNameInput.setText("New Profile") + baseUrlInput.setText("https://api.openai.com") + modelNameInput.setText("gpt-4o-mini") + apiKeyInput.setText("") + profileSelector.setText("(New Profile)", false) + } else { + selectedId = profiles[pos].id + CustomProviderProfileStore.setActive(profiles[pos].id) + activeId = profiles[pos].id + bindSelected() + setupSelector() + val p = profiles[pos] + onSave(p.baseUrl, p.modelName, p.apiKey) + } + } } - private fun saveConfig(baseUrl: String, modelName: String, apiKey: String) { - val prefs = BaseApplication.getBaseInstance().prefManager - prefs.putString("ai_agent_custom_base_url", baseUrl) - prefs.putString("ai_agent_custom_model_name", modelName) - prefs.putString("ai_agent_custom_api_key", apiKey) + private fun bindSelected() { + val p = profiles.firstOrNull { it.id == selectedId } ?: profiles.firstOrNull() ?: return + profileNameInput.setText(p.name) + baseUrlInput.setText(p.baseUrl) + modelNameInput.setText(p.modelName) + apiKeyInput.setText(p.apiKey) } } diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/McpServersDialog.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/McpServersDialog.kt new file mode 100644 index 0000000000..d6bd87f4b0 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/McpServersDialog.kt @@ -0,0 +1,91 @@ +package com.tom.rv2ide.artificial.dialogs + +import android.content.Context +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.Toast +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.tom.rv2ide.artificial.mcp.McpPresets +import com.tom.rv2ide.artificial.mcp.McpServerConfig +import com.tom.rv2ide.artificial.mcp.McpServerStore + +object McpServersDialog { + fun show(context: Context, onChanged: () -> Unit = {}) { + val servers = McpServerStore.load() + val names = servers.map { "${if (it.enabled) "āœ…" else "āøļø"} ${it.name}" }.toTypedArray() + arrayOf("+ Add MCP server", "+ Add prebuilt MCP preset") + MaterialAlertDialogBuilder(context) + .setTitle("MCP Servers") + .setMessage("HTTP MCP servers for agent tools. Prebuilt presets (GitHub, Firebase, Stitch, Vercel, Cloudflare, Supabase) can be added and configured.") + .setItems(names) { d, which -> + d.dismiss() + when { + which < servers.size -> manage(context, servers[which], onChanged) + which == servers.size -> edit(context, null, onChanged) + else -> showPresets(context, onChanged) + } + } + .setNegativeButton("Close", null) + .show() + } + + private fun showPresets(context: Context, onChanged: () -> Unit) { + val presets = McpPresets.all() + MaterialAlertDialogBuilder(context) + .setTitle("Prebuilt MCP Presets") + .setItems(presets.map { it.name }.toTypedArray()) { d, which -> + d.dismiss() + McpServerStore.addPreset(presets[which]) + Toast.makeText(context, "Preset added (edit to configure)", Toast.LENGTH_SHORT).show() + onChanged(); show(context, onChanged) + } + .setNegativeButton("Back") { _, _ -> show(context, onChanged) } + .show() + } + + private fun manage(context: Context, server: McpServerConfig, onChanged: () -> Unit) { + MaterialAlertDialogBuilder(context) + .setTitle(server.name) + .setMessage(server.url) + .setItems(arrayOf(if (server.enabled) "Disable" else "Enable", "Edit", "Delete")) { d, which -> + d.dismiss() + when (which) { + 0 -> { McpServerStore.upsert(server.copy(enabled = !server.enabled)); onChanged(); show(context, onChanged) } + 1 -> edit(context, server, onChanged) + 2 -> { McpServerStore.delete(server.id); onChanged(); show(context, onChanged) } + } + } + .setNegativeButton("Back") { _, _ -> show(context, onChanged) } + .show() + } + + private fun edit(context: Context, existing: McpServerConfig?, onChanged: () -> Unit) { + val container = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL; setPadding(48, 24, 48, 0) } + fun field(hint: String, value: String): TextInputEditText { + val e = TextInputEditText(context).apply { setText(value) } + container.addView(TextInputLayout(context).apply { this.hint = hint; addView(e); setPadding(0, 8, 0, 0) }) + return e + } + val name = field("Name", existing?.name ?: "") + val url = field("URL (HTTP MCP endpoint)", existing?.url ?: "https://") + val headers = field("Headers (JSON, optional)", if (existing?.headers.isNullOrEmpty()) "" else org.json.JSONObject(existing!!.headers).toString()) + val scroll = ScrollView(context).apply { addView(container) } + MaterialAlertDialogBuilder(context) + .setTitle(if (existing == null) "Add MCP Server" else "Edit MCP Server") + .setView(scroll) + .setPositiveButton("Save") { _, _ -> + val n = name.text?.toString()?.trim().orEmpty().ifBlank { "MCP Server" } + val u = url.text?.toString()?.trim().orEmpty() + val h = mutableMapOf() + try { + val ht = headers.text?.toString()?.trim().orEmpty() + if (ht.isNotBlank()) { val obj = org.json.JSONObject(ht); obj.keys().forEach { k -> h[k] = obj.optString(k) } } + } catch (_: Exception) { Toast.makeText(context, "Headers JSON ignored (invalid)", Toast.LENGTH_SHORT).show() } + McpServerStore.upsert((existing ?: McpServerConfig(name = n, url = u)).copy(name = n, url = u, headers = h)) + onChanged(); show(context, onChanged) + } + .setNegativeButton("Cancel") { _, _ -> show(context, onChanged) } + .show() + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt new file mode 100644 index 0000000000..7e1cb006d5 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt @@ -0,0 +1,140 @@ +package com.tom.rv2ide.artificial.mcp +import com.tom.rv2ide.artificial.tools.AIToolDef + +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject + +/** + * Minimal MCP client (HTTP JSON-RPC). Intended for remote/HTTP MCP servers. + * Supports initialize, tools/list, tools/call. Some servers use Streamable HTTP + * semantics; this implementation uses plain JSON-RPC POST requests for broad + * compatibility and surfaces errors to the agent. + */ +class McpClient(private val config: McpServerConfig) { + private val http = OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + private val nextId = AtomicInteger(1) + @Volatile private var initialized = false + + fun listTools(): List { + ensureInitialized() + val res = rpc("tools/list", JSONObject()) ?: return emptyList() + val tools = res.optJSONObject("result")?.optJSONArray("tools") ?: res.optJSONArray("tools") ?: return emptyList() + val out = mutableListOf() + for (i in 0 until tools.length()) { + val t = tools.optJSONObject(i) ?: continue + val name = t.optString("name").ifBlank { continue } + val desc = t.optString("description", "") + val schema = t.optJSONObject("inputSchema")?.toString() ?: "{\"type\":\"object\",\"properties\":{}}" + out.add(AIToolDef(name = "mcp__${safe(config.name)}__${name}", description = "[MCP:${config.name}] $desc", parametersJsonSchema = schema)) + } + return out + } + + fun callTool(toolName: String, arguments: JSONObject): String { + ensureInitialized() + val actual = toolName.substringAfterLast("__", toolName).let { + if (toolName.startsWith("mcp__")) toolName.split("__").getOrNull(2) ?: it else toolName + } + val params = JSONObject().put("name", actual).put("arguments", arguments) + val res = rpc("tools/call", params) ?: return "Error: no response from MCP server" + if (res.has("error")) return "MCP error: ${res.opt("error")}" + val result = res.optJSONObject("result") ?: return res.toString() + val content = result.optJSONArray("content") + if (content != null) { + val sb = StringBuilder() + for (i in 0 until content.length()) { + val c = content.optJSONObject(i) ?: continue + if (c.optString("type") == "text") sb.append(c.optString("text")) + else sb.append(c.toString()) + } + val text = sb.toString() + if (text.isNotBlank()) return text + } + if (result.has("text")) return result.optString("text") + return result.toString() + } + + @Synchronized + private fun ensureInitialized() { + if (initialized) return + val params = JSONObject().apply { + put("protocolVersion", "2024-11-05") + put("capabilities", JSONObject()) + put("clientInfo", JSONObject().put("name", "AndroidCodeStudio").put("version", "1.0")) + } + rpc("initialize", params) + // best-effort initialized notification (some servers require/ignore) + try { notify("notifications/initialized", JSONObject()) } catch (_: Exception) {} + initialized = true + } + + private fun rpc(method: String, params: JSONObject): JSONObject? { + val body = JSONObject().apply { + put("jsonrpc", "2.0"); put("id", nextId.getAndIncrement()); put("method", method); put("params", params) + } + return post(body) + } + + private fun notify(method: String, params: JSONObject) { + val body = JSONObject().apply { put("jsonrpc", "2.0"); put("method", method); put("params", params) } + post(body) + } + + private fun post(body: JSONObject): JSONObject? { + if (config.url.isBlank()) return null + val reqBuilder = Request.Builder() + .url(config.url.trim()) + .post(body.toString().toRequestBody("application/json".toMediaType())) + .addHeader("Accept", "application/json, text/event-stream") + config.headers.forEach { (k, v) -> if (k.isNotBlank()) reqBuilder.addHeader(k, v) } + http.newCall(reqBuilder.build()).execute().use { resp -> + val text = resp.body?.string().orEmpty() + if (text.isBlank()) return if (resp.isSuccessful) JSONObject() else JSONObject().put("error", "HTTP ${resp.code}") + // handle SSE responses (data: lines) + if (text.startsWith("event:") || text.startsWith("data:") || resp.header("Content-Type")?.contains("text/event-stream") == true) { + val dataLines = text.lines().filter { it.startsWith("data:") }.map { it.removePrefix("data:").trim() }.filter { it.isNotBlank() && it != "[DONE]" } + for (line in dataLines.asReversed()) { + try { + val o = JSONObject(line) + if (o.has("result") || o.has("error")) return o + } catch (_: Exception) {} + } + return try { JSONObject(dataLines.lastOrNull() ?: "{}") } catch (_: Exception) { JSONObject().put("error", text.take(500)) } + } + return try { JSONObject(text) } catch (_: Exception) { JSONObject().put("error", text.take(500)) } + } + } + + private fun safe(s: String) = s.replace(Regex("[^a-zA-Z0-9_-]"), "_").take(40) +} + +class McpToolExecutor(private val servers: List) : com.tom.rv2ide.artificial.tools.AIToolExecutor { + private val clients = servers.filter { it.enabled && it.url.isNotBlank() }.map { it to McpClient(it) } + + override fun definitions(): List = clients.flatMap { (cfg, client) -> + try { client.listTools() } catch (e: Exception) { + listOf(AIToolDef("mcp__${cfg.name}__status", "[MCP:${cfg.name}] unavailable: ${e.message}", "{\"type\":\"object\",\"properties\":{}}")) + } + } + + override fun execute(name: String, arguments: JSONObject): String { + if (!name.startsWith("mcp__")) return "Error: not an MCP tool" + val parts = name.split("__") + val serverName = parts.getOrNull(1) ?: return "Error: invalid MCP tool name" + val (_, client) = clients.firstOrNull { safe(it.first.name) == serverName || it.first.name == serverName } + ?: return "Error: MCP server not found/enabled: $serverName" + return try { client.callTool(name, arguments) } catch (e: Exception) { "MCP error: ${e.message}" } + } + + private fun safe(s: String) = s.replace(Regex("[^a-zA-Z0-9_-]"), "_").take(40) +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpModels.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpModels.kt new file mode 100644 index 0000000000..7ec971366c --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpModels.kt @@ -0,0 +1,81 @@ +package com.tom.rv2ide.artificial.mcp + +import com.tom.rv2ide.app.BaseApplication +import java.util.UUID +import org.json.JSONArray +import org.json.JSONObject + +data class McpServerConfig( + val id: String = UUID.randomUUID().toString(), + val name: String, + val url: String, + val enabled: Boolean = true, + val headers: Map = emptyMap(), + val preset: String? = null, +) + +object McpPresets { + fun all(): List = listOf( + McpServerConfig(id = "preset-github", name = "GitHub (preset)", url = "https://api.githubcopilot.com/mcp/", enabled = false, headers = mapOf("Authorization" to "Bearer "), preset = "github"), + McpServerConfig(id = "preset-firebase", name = "Firebase (preset)", url = "https://", enabled = false, preset = "firebase"), + McpServerConfig(id = "preset-stitch", name = "Stitch (preset)", url = "https://", enabled = false, preset = "stitch"), + McpServerConfig(id = "preset-vercel", name = "Vercel (preset)", url = "https://", enabled = false, headers = mapOf("Authorization" to "Bearer "), preset = "vercel"), + McpServerConfig(id = "preset-cloudflare", name = "Cloudflare (preset)", url = "https://", enabled = false, headers = mapOf("Authorization" to "Bearer "), preset = "cloudflare"), + McpServerConfig(id = "preset-supabase", name = "Supabase (preset)", url = "https://", enabled = false, headers = mapOf("Authorization" to "Bearer "), preset = "supabase"), + McpServerConfig(id = "preset-custom", name = "Custom MCP (template)", url = "https://", enabled = false, preset = "custom"), + ) +} + +object McpServerStore { + private const val KEY = "ai_agent_mcp_servers_v1" + private fun prefs() = BaseApplication.getBaseInstance().prefManager + + @Synchronized + fun load(): List { + val raw = prefs().getString(KEY, null) ?: return emptyList() + return try { + val arr = JSONArray(raw) + (0 until arr.length()).mapNotNull { i -> + val o = arr.optJSONObject(i) ?: return@mapNotNull null + val name = o.optString("name").ifBlank { return@mapNotNull null } + val headers = mutableMapOf() + val h = o.optJSONObject("headers") + h?.keys()?.forEach { k -> headers[k] = h.optString(k) } + McpServerConfig( + id = o.optString("id").ifBlank { UUID.randomUUID().toString() }, + name = name, + url = o.optString("url", ""), + enabled = o.optBoolean("enabled", true), + headers = headers, + preset = o.optString("preset").ifBlank { null }, + ) + } + } catch (_: Exception) { emptyList() } + } + + @Synchronized + fun save(servers: List) { + val arr = JSONArray() + servers.forEach { s -> + arr.put(JSONObject().apply { + put("id", s.id); put("name", s.name); put("url", s.url); put("enabled", s.enabled) + put("headers", JSONObject(s.headers)); put("preset", s.preset ?: "") + }) + } + prefs().putString(KEY, arr.toString()) + } + + fun upsert(server: McpServerConfig) { + val list = load().toMutableList() + val idx = list.indexOfFirst { it.id == server.id } + if (idx >= 0) list[idx] = server else list.add(server) + save(list) + } + + fun delete(id: String) = save(load().filterNot { it.id == id }) + + fun addPreset(preset: McpServerConfig) { + val copy = preset.copy(id = UUID.randomUUID().toString(), enabled = false) + upsert(copy) + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt new file mode 100644 index 0000000000..239fa5ae1c --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt @@ -0,0 +1,121 @@ +package com.tom.rv2ide.artificial.profiles + +import com.tom.rv2ide.app.BaseApplication +import java.util.UUID +import org.json.JSONArray +import org.json.JSONObject + +data class AgentPromptProfile( + val id: String = UUID.randomUUID().toString(), + val name: String, + val systemPrompt: String = "", + val enableTools: Boolean = true, + val enableMcpTools: Boolean = true, + val allowCommandExecution: Boolean = false, + val enabledMcpServerIds: List = emptyList(), + val skillPreset: String? = null, +) + +object SkillPresets { + fun all(): List = listOf( + AgentPromptProfile(id = "skill-file-reader", name = "Skill: File Reader", systemPrompt = "Focus on reading and understanding project files. Use read_file/list_files/search_project/get_project_tree before making claims. Prefer citing exact paths and line numbers.", enableTools = true, enableMcpTools = false, skillPreset = "file_reader"), + AgentPromptProfile(id = "skill-command-run", name = "Skill: Command Run (opt-in)", systemPrompt = "You may use run_command only when explicitly enabled and necessary. Prefer safe, read-only commands. Explain what a command does before running it. Never run destructive commands without explicit user confirmation in chat.", enableTools = true, enableMcpTools = false, allowCommandExecution = false, skillPreset = "command_run"), + AgentPromptProfile(id = "skill-file-modification", name = "Skill: File Modification", systemPrompt = "Focus on precise code/file edits. Read relevant files first when needed, then produce FILE_TO_MODIFY blocks with complete, correct file contents. Preserve unrelated code.", enableTools = true, enableMcpTools = false, skillPreset = "file_modification"), + AgentPromptProfile(id = "skill-stitch", name = "Skill: Stitch", systemPrompt = "You are a UI/product implementation assistant for Stitch-style workflows. Turn requirements into clear implementation steps and Android/UI code changes. Use available MCP/tools if configured. When editing files, use FILE_TO_MODIFY with exact project paths.", enableTools = true, enableMcpTools = true, skillPreset = "stitch"), + AgentPromptProfile(id = "skill-design", name = "Skill: Design", systemPrompt = "You are a software design assistant. Produce clear architecture, UX, and implementation guidance. Prefer practical Android/Gradle/Kotlin patterns. When the user asks for file changes, output FILE_TO_MODIFY blocks.", enableTools = true, enableMcpTools = true, skillPreset = "design"), + AgentPromptProfile(id = "skill-custom", name = "Skill: Custom", systemPrompt = "", enableTools = true, enableMcpTools = true, skillPreset = "custom"), + ) +} + +object AgentPromptProfileStore { + private const val KEY = "ai_agent_prompt_profiles_v1" + private fun prefs() = BaseApplication.getBaseInstance().prefManager + + @Synchronized + fun load(): Pair> { + ensureDefault() + val raw = prefs().getString(KEY, null) ?: return null to emptyList() + return try { + val obj = JSONObject(raw) + val activeId = obj.optString("activeId").ifBlank { null } + val arr = obj.optJSONArray("profiles") ?: JSONArray() + val profiles = mutableListOf() + for (i in 0 until arr.length()) { + val p = arr.optJSONObject(i) ?: continue + val name = p.optString("name").ifBlank { continue } + val serverIds = mutableListOf() + val sArr = p.optJSONArray("enabledMcpServerIds") ?: JSONArray() + for (j in 0 until sArr.length()) serverIds.add(sArr.optString(j)) + profiles.add( + AgentPromptProfile( + id = p.optString("id").ifBlank { UUID.randomUUID().toString() }, + name = name, + systemPrompt = p.optString("systemPrompt", ""), + enableTools = p.optBoolean("enableTools", true), + enableMcpTools = p.optBoolean("enableMcpTools", true), + allowCommandExecution = p.optBoolean("allowCommandExecution", false), + enabledMcpServerIds = serverIds, + skillPreset = p.optString("skillPreset").ifBlank { null }, + ) + ) + } + activeId to profiles + } catch (_: Exception) { null to emptyList() } + } + + @Synchronized + fun save(activeId: String?, profiles: List) { + val obj = JSONObject() + obj.put("activeId", activeId ?: "") + val arr = JSONArray() + profiles.forEach { p -> + arr.put(JSONObject().apply { + put("id", p.id); put("name", p.name); put("systemPrompt", p.systemPrompt) + put("enableTools", p.enableTools); put("enableMcpTools", p.enableMcpTools) + put("allowCommandExecution", p.allowCommandExecution) + put("enabledMcpServerIds", JSONArray(p.enabledMcpServerIds)) + put("skillPreset", p.skillPreset ?: "") + }) + } + obj.put("profiles", arr) + prefs().putString(KEY, obj.toString()) + } + + fun getActive(): AgentPromptProfile? { + val (activeId, profiles) = load() + return profiles.firstOrNull { it.id == activeId } ?: profiles.firstOrNull() + } + + fun upsert(profile: AgentPromptProfile, makeActive: Boolean = false): AgentPromptProfile { + val (activeId, profiles) = load() + val mutable = profiles.toMutableList() + val idx = mutable.indexOfFirst { it.id == profile.id } + if (idx >= 0) mutable[idx] = profile else mutable.add(profile) + save(if (makeActive || activeId == null) profile.id else activeId, mutable) + return profile + } + + fun delete(id: String) { + val (activeId, profiles) = load() + if (profiles.size <= 1) return + val remaining = profiles.filterNot { it.id == id } + save(if (activeId == id) remaining.firstOrNull()?.id else activeId, remaining) + } + + fun setActive(id: String) { + val (_, profiles) = load() + if (profiles.any { it.id == id }) save(id, profiles) + } + + @Synchronized + private fun ensureDefault() { + if (prefs().getString(KEY, null) != null) return + val def = AgentPromptProfile(name = "Default", systemPrompt = "", enableTools = true, enableMcpTools = true) + save(def.id, listOf(def)) + } + + fun addPreset(preset: AgentPromptProfile): AgentPromptProfile { + val copy = preset.copy(id = UUID.randomUUID().toString()) + return upsert(copy, makeActive = false) + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/CustomProviderProfile.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/CustomProviderProfile.kt new file mode 100644 index 0000000000..8a1717da7a --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/CustomProviderProfile.kt @@ -0,0 +1,124 @@ +package com.tom.rv2ide.artificial.profiles + +import com.tom.rv2ide.app.BaseApplication +import java.util.UUID +import org.json.JSONArray +import org.json.JSONObject + +data class CustomProviderProfile( + val id: String = UUID.randomUUID().toString(), + val name: String, + val baseUrl: String, + val modelName: String, + val apiKey: String = "", +) + +object CustomProviderProfileStore { + private const val KEY = "ai_agent_custom_profiles_v1" + private const val LEGACY_BASE_URL = "ai_agent_custom_base_url" + private const val LEGACY_MODEL = "ai_agent_custom_model_name" + private const val LEGACY_API_KEY = "ai_agent_custom_api_key" + + private fun prefs() = BaseApplication.getBaseInstance().prefManager + + @Synchronized + fun load(): Pair> { + migrateLegacyIfNeeded() + val raw = prefs().getString(KEY, null) ?: return null to emptyList() + return try { + val obj = JSONObject(raw) + val activeId = obj.optString("activeId").ifBlank { null } + val arr = obj.optJSONArray("profiles") ?: JSONArray() + val profiles = mutableListOf() + for (i in 0 until arr.length()) { + val p = arr.optJSONObject(i) ?: continue + val name = p.optString("name").ifBlank { continue } + profiles.add( + CustomProviderProfile( + id = p.optString("id").ifBlank { UUID.randomUUID().toString() }, + name = name, + baseUrl = p.optString("baseUrl", ""), + modelName = p.optString("modelName", ""), + apiKey = p.optString("apiKey", ""), + ) + ) + } + activeId to profiles + } catch (_: Exception) { + null to emptyList() + } + } + + @Synchronized + fun save(activeId: String?, profiles: List) { + val obj = JSONObject() + obj.put("activeId", activeId ?: "") + val arr = JSONArray() + profiles.forEach { p -> + arr.put( + JSONObject().apply { + put("id", p.id) + put("name", p.name) + put("baseUrl", p.baseUrl) + put("modelName", p.modelName) + put("apiKey", p.apiKey) + } + ) + } + obj.put("profiles", arr) + prefs().putString(KEY, obj.toString()) + syncLegacy(activeId, profiles) + } + + fun getActive(): CustomProviderProfile? { + val (activeId, profiles) = load() + if (profiles.isEmpty()) return null + return profiles.firstOrNull { it.id == activeId } ?: profiles.first() + } + + fun upsert(profile: CustomProviderProfile, makeActive: Boolean = false): CustomProviderProfile { + val (activeId, profiles) = load() + val mutable = profiles.toMutableList() + val idx = mutable.indexOfFirst { it.id == profile.id } + if (idx >= 0) mutable[idx] = profile else mutable.add(profile) + val newActive = if (makeActive || activeId == null) profile.id else activeId + save(newActive, mutable) + return profile + } + + fun delete(id: String) { + val (activeId, profiles) = load() + val remaining = profiles.filterNot { it.id == id } + val newActive = if (activeId == id) remaining.firstOrNull()?.id else activeId + save(newActive, remaining) + } + + fun setActive(id: String) { + val (_, profiles) = load() + if (profiles.any { it.id == id }) save(id, profiles) + } + + @Synchronized + private fun migrateLegacyIfNeeded() { + if (prefs().getString(KEY, null) != null) return + val baseUrl = prefs().getString(LEGACY_BASE_URL, "") ?: "" + val model = prefs().getString(LEGACY_MODEL, "") ?: "" + val apiKey = prefs().getString(LEGACY_API_KEY, "") ?: "" + if (baseUrl.isBlank() && model.isBlank() && apiKey.isBlank()) return + val profile = + CustomProviderProfile( + name = "Default", + baseUrl = baseUrl, + modelName = model.ifBlank { "gpt-4o-mini" }, + apiKey = apiKey, + ) + save(profile.id, listOf(profile)) + } + + private fun syncLegacy(activeId: String?, profiles: List) { + val active = profiles.firstOrNull { it.id == activeId } ?: profiles.firstOrNull() + prefs().putString(LEGACY_BASE_URL, active?.baseUrl ?: "") + prefs().putString(LEGACY_MODEL, active?.modelName ?: "") + prefs().putString(LEGACY_API_KEY, active?.apiKey ?: "") + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/tools/AITools.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/tools/AITools.kt new file mode 100644 index 0000000000..da562b7300 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/tools/AITools.kt @@ -0,0 +1,150 @@ +package com.tom.rv2ide.artificial.tools + +import android.content.Context +import com.tom.rv2ide.artificial.file.AIFileWriter +import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult +import java.io.File +import java.util.concurrent.TimeUnit +import org.json.JSONObject + +data class AIToolDef( + val name: String, + val description: String, + val parametersJsonSchema: String, +) + +data class AIToolCall(val name: String, val arguments: JSONObject, val raw: String) + +interface AIToolExecutor { + fun definitions(): List + fun execute(name: String, arguments: JSONObject): String +} + +class BuiltInToolExecutor( + private val context: Context, + private var projectTree: ProjectTreeResult? = null, + private var projectRoot: File? = null, + private val allowCommandExecution: Boolean = false, +) : AIToolExecutor { + private val writer = AIFileWriter(context) + + fun updateProject(tree: ProjectTreeResult?, root: File?) { + projectTree = tree + projectRoot = root + } + + override fun definitions(): List { + val defs = mutableListOf( + AIToolDef("read_file", "Read a UTF-8 text file from the project. Use exact paths.", """{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}"""), + AIToolDef("list_files", "List files under a directory path (non-recursive).", """{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}"""), + AIToolDef("search_project", "Search for text in project source files. Returns matching path:line previews.", """{"type":"object","properties":{"query":{"type":"string"},"maxResults":{"type":"integer"}},"required":["query"]}"""), + AIToolDef("get_project_tree", "Get the project file tree/structure.", """{"type":"object","properties":{}}"""), + AIToolDef("modify_file", "Create or overwrite a project text file. Prefer FILE_TO_MODIFY blocks for normal edits; this tool is for targeted agentic operations.", """{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}"""), + ) + if (allowCommandExecution) { + defs.add(AIToolDef("run_command", "Run a shell command in the project directory (opt-in, use with caution). Returns stdout/stderr and exit code.", """{"type":"object","properties":{"command":{"type":"string"},"timeoutMs":{"type":"integer"}},"required":["command"]}""")) + } + return defs + } + + override fun execute(name: String, arguments: JSONObject): String { + return try { + when (name) { + "read_file" -> { + val path = arguments.optString("path") + if (path.isBlank()) return "Error: path is required" + val f = File(path) + if (!f.exists() || !f.isFile) return "Error: file not found: $path" + val text = f.readText() + if (text.length > 12000) text.take(12000) + "\n...[truncated]..." else text + } + "list_files" -> { + val path = arguments.optString("path") + val f = File(path) + if (!f.exists() || !f.isDirectory) return "Error: directory not found: $path" + f.listFiles()?.sortedBy { it.name }?.joinToString("\n") { if (it.isDirectory) "${it.name}/" else it.name } ?: "" + } + "search_project" -> { + val query = arguments.optString("query") + if (query.isBlank()) return "Error: query is required" + val max = arguments.optInt("maxResults", 30).coerceIn(1, 100) + val root = projectRoot ?: return "Error: no project root" + val results = mutableListOf() + root.walkTopDown() + .filter { it.isFile && it.extension in listOf("kt","java","xml","gradle","kts","json","md","properties") } + .filter { !it.path.contains("/build/") && !it.path.contains("/.gradle/") && !it.path.contains("/.git/") } + .forEach { file -> + if (results.size >= max) return@forEach + try { file.readLines().forEachIndexed { idx, line -> + if (results.size >= max) return@forEachIndexed + if (line.contains(query, ignoreCase = true)) results.add("${file.absolutePath}:${idx + 1}: ${line.trim().take(200)}") + } } catch (_: Exception) {} + } + if (results.isEmpty()) "No matches." else results.joinToString("\n") + } + "get_project_tree" -> projectTree?.tree ?: "No project tree available." + "modify_file" -> { + val path = arguments.optString("path") + val content = arguments.optString("content") + if (path.isBlank()) return "Error: path is required" + when (val r = writer.writeFile(path, content, createBackup = true)) { + is com.tom.rv2ide.artificial.file.FileWriteResult.Success -> "OK: wrote ${r.path}" + is com.tom.rv2ide.artificial.file.FileWriteResult.Error -> "Error: ${r.message}" + else -> "Error: failed to write file" + } + } + "run_command" -> { + if (!allowCommandExecution) return "Error: command execution is disabled by the active agent profile." + val command = arguments.optString("command") + if (command.isBlank()) return "Error: command is required" + val timeoutMs = arguments.optLong("timeoutMs", 15000L).coerceIn(1000L, 60000L) + val workdir = projectRoot ?: File(context.filesDir.path) + val proc = ProcessBuilder("sh", "-c", command).directory(workdir).redirectErrorStream(true).start() + val finished = proc.waitFor(timeoutMs, TimeUnit.MILLISECONDS) + if (!finished) { proc.destroyForcibly(); return "Error: command timed out after ${timeoutMs}ms" } + val out = proc.inputStream.bufferedReader().readText() + val clipped = if (out.length > 8000) out.take(8000) + "\n...[truncated]" else out + "exit=${proc.exitValue()}\n$clipped" + } + else -> "Error: unknown tool '$name'" + } + } catch (e: Exception) { + "Error: ${e.message}" + } + } +} + +object ToolCallProtocol { + private val patterns = listOf( + Regex("""TOOL_CALL:\s*(\{.*\})""", RegexOption.DOT_MATCHES_ALL), + Regex("""```tool_call\s*\n(\{.*?\})\s*\n```""", RegexOption.DOT_MATCHES_ALL), + Regex("""```json\s*\n(\{\s*"(?:name|tool)"\s*:.*?\})\s*\n```""", RegexOption.DOT_MATCHES_ALL), + ) + + fun parse(response: String): AIToolCall? { + for (re in patterns) { + val m = re.find(response) ?: continue + val jsonText = m.groupValues[1].trim() + try { + val obj = JSONObject(jsonText) + val name = obj.optString("name").ifBlank { obj.optString("tool") } + if (name.isBlank()) continue + val args = obj.optJSONObject("arguments") ?: obj.optJSONObject("args") ?: JSONObject() + return AIToolCall(name, args, m.value) + } catch (_: Exception) {} + } + return null + } + + fun formatDefinitions(tools: List): String { + if (tools.isEmpty()) return "" + val sb = StringBuilder() + sb.append("\n=== AVAILABLE TOOLS / SKILLS ===\n") + sb.append("You can use tools to gather information or perform allowed actions.\n") + sb.append("To call a tool, output ONLY:\nTOOL_CALL: {\"name\":\"tool_name\",\"arguments\":{...}}\n\n") + sb.append("After TOOL_RESULT, continue. When done, respond normally (use FILE_TO_MODIFY for file edits).\n\n") + tools.forEach { t -> sb.append("- ${t.name}: ${t.description}\n parameters: ${t.parametersJsonSchema}\n") } + sb.append("\n") + return sb.toString() + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt b/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt index 1731b2d47c..94bfcb9d3d 100644 --- a/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt +++ b/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt @@ -23,6 +23,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import com.tom.rv2ide.artificial.dialogs.LocalLLMConfigDialog import com.tom.rv2ide.artificial.dialogs.CustomProviderConfigDialog +import com.tom.rv2ide.artificial.dialogs.AgentProfilesDialog +import com.tom.rv2ide.artificial.dialogs.McpServersDialog class AIPreferencesFragment( private val aiAgent: AIAgentManager, @@ -57,6 +59,7 @@ class AIPreferencesFragment( setupProviderDropdown() setupModelDropdown() setupToggles() + setupAgentCapabilityButtons(view) updateCurrentStatus() startCompletionStateMonitoring() } @@ -346,4 +349,9 @@ class AIPreferencesFragment( stopCompletionStateMonitoring() super.onDestroyView() } + + private fun setupAgentCapabilityButtons(view: View) { + view.findViewById(R.id.agentProfilesButton)?.setOnClickListener { AgentProfilesDialog.show(requireContext()) } + view.findViewById(R.id.mcpServersButton)?.setOnClickListener { McpServersDialog.show(requireContext()) } + } } diff --git a/core/app/src/main/res/layout/dialog_custom_provider_config.xml b/core/app/src/main/res/layout/dialog_custom_provider_config.xml index 708569fc66..40f1fd0dd8 100644 --- a/core/app/src/main/res/layout/dialog_custom_provider_config.xml +++ b/core/app/src/main/res/layout/dialog_custom_provider_config.xml @@ -11,7 +11,7 @@ @@ -19,57 +19,84 @@ + android:layout_marginBottom="16dp" /> + + + + + + + + + - + android:inputType="textUri" /> - - + android:inputType="text" /> - - - + + - - - diff --git a/core/app/src/main/res/layout/fragment_ai_preferences.xml b/core/app/src/main/res/layout/fragment_ai_preferences.xml index 6bd6157bb0..792f436524 100644 --- a/core/app/src/main/res/layout/fragment_ai_preferences.xml +++ b/core/app/src/main/res/layout/fragment_ai_preferences.xml @@ -173,6 +173,41 @@ + + + + + + + + + + - \ No newline at end of file + From 28568cd4182a1003781332277b92f7ac8e68260f Mon Sep 17 00:00:00 2001 From: Nabin_sharma <105613345+NabinSharmaitgithub@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:56:14 +0545 Subject: [PATCH 3/6] feat(ai): native tool-calling foundation, per-tool toggles, MCP streamable HTTP headers - Extend AIAgent with optional native tool-calling API (backward compatible defaults) - AIAgentManager prefers native tool path when provider supports it (fallback to text protocol) - CustomProvider: full OpenAI-style native tool calling with tool_call/tool result messages - Per-tool enable/disable (checkboxes) in Agent Profile UI; profile store supports disabledTools - MCP client: Mcp-Session-Id handling, MCP-Protocol-Version header, improved SSE/JSON and 202/204 handling --- .../tom/rv2ide/artificial/agents/AIAgent.kt | 34 ++- .../artificial/agents/AIAgentManager.kt | 241 ++++++---------- .../agents/custom/CustomProvider.kt | 262 ++++++++---------- .../artificial/dialogs/AgentProfilesDialog.kt | 28 +- .../tom/rv2ide/artificial/mcp/McpClient.kt | 56 ++-- .../artificial/profiles/AgentPromptProfile.kt | 85 +++--- .../tom/rv2ide/artificial/tools/AITools.kt | 35 ++- 7 files changed, 352 insertions(+), 389 deletions(-) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgent.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgent.kt index b35bdac8f2..90a1804343 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgent.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgent.kt @@ -20,6 +20,10 @@ package com.tom.rv2ide.artificial.agents import android.content.Context import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult import com.tom.rv2ide.artificial.file.FileWriteResult +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol /* * @author Mohammed-baqer-null @ https://github.com/Mohammed-baqer-null @@ -53,6 +57,34 @@ interface AIAgent { fun writeFile(filePath: String, content: String): FileWriteResult fun isInitialized(): Boolean + + /** Native tool/function-calling support (optional). */ + fun supportsNativeTools(): Boolean = false + + suspend fun generateWithTools( + prompt: String, + context: String?, + language: String, + projectStructure: String?, + tools: List + ): Result { + return generateCode(prompt, context, language, projectStructure).map { text -> + val tc = ToolCallProtocol.parse(text) + if (tc != null) ToolAwareResponse("", listOf(tc)) else ToolAwareResponse(text) + } + } + + suspend fun submitToolResults( + toolResults: List, + tools: List + ): Result { + val prompt = buildString { + append("=== TOOL RESULTS ===\n") + toolResults.forEach { tr -> append("Tool: ${tr.name}\nResult:\n${tr.result}\n\n") } + append("Continue. If you need another tool, call it. Otherwise provide the final answer and use FILE_TO_MODIFY for file edits.") + } + return generateWithTools(prompt, null, "kotlin", null, tools) + } } data class ModificationAttempt( @@ -62,4 +94,4 @@ data class ModificationAttempt( val newContent: String, val attemptNumber: Int = 0, val success: Boolean = false -) \ No newline at end of file +) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt index d7d08d0377..e45f9e107b 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt @@ -33,16 +33,15 @@ import com.tom.rv2ide.artificial.permissions.AIPermissionManager import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore import com.tom.rv2ide.artificial.project.awareness.ProjectData import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.tools.ToolAwareResponse import com.tom.rv2ide.artificial.tools.ToolCallProtocol import java.io.File import kotlinx.coroutines.delay import com.tom.rv2ide.artificial.dialogs.ProviderSwitchDialog -/** - * @author Mohammed-baqer-null @ https://github.com/Mohammed-baqer-null - */ - class AIAgentManager(private val context: Context) { private val snippetParser = SnippetParser() @@ -61,61 +60,40 @@ class AIAgentManager(private val context: Context) { DeepSeek.registerAgent() LocalLLM.registerAgent() CustomProvider.registerAgent() - permissionManager.setFileWriteEnabled(true) permissionManager.setRequireConfirmation(false) - setProvider(currentProviderId) } fun getCurrentAgent(): AIAgent? = currentAgent fun setProvider(providerId: String): Boolean { - android.util.Log.d("AIAgentManager", "setProvider called with: $providerId") - - val factory = AIAgentRegistry.getFactory(providerId) - if (factory == null) { - android.util.Log.e("AIAgentManager", "No factory found for provider: $providerId") - return false - } - - if (!factory.hasValidApiKey()) { - android.util.Log.e("AIAgentManager", "No valid API key for provider: $providerId") - return false - } - + val factory = AIAgentRegistry.getFactory(providerId) ?: return false + if (!factory.hasValidApiKey()) return false currentProviderId = providerId currentAgent = factory.create(context) - android.util.Log.d("AIAgentManager", "Agent created: ${currentAgent != null}") - factory.getApiKey()?.let { apiKey -> - android.util.Log.d("AIAgentManager", "Initializing agent with API key") currentAgent?.initialize(apiKey, context) currentAgent?.setContext(context) currentProjectTree?.let { currentAgent?.setProjectData(it) } - android.util.Log.d("AIAgentManager", "Agent initialized: ${currentAgent?.isInitialized()}") } - return currentAgent?.isInitialized() ?: false } fun getCurrentProviderId(): String = currentProviderId fun getCurrentProviderName(): String = currentAgent?.providerName ?: "Unknown" - fun getAvailableProviders(): List { - return AIAgentRegistry.getAvailableProviders().mapNotNull { providerId -> + fun getAvailableProviders(): List = + AIAgentRegistry.getAvailableProviders().mapNotNull { providerId -> val factory = AIAgentRegistry.getFactory(providerId) - val agent = factory?.create(context) - agent?.let { ProviderInfo(id = it.providerId, name = it.providerName, isAvailable = factory.hasValidApiKey()) } + factory?.create(context)?.let { ProviderInfo(id = it.providerId, name = it.providerName, isAvailable = factory.hasValidApiKey()) } } - } fun setProjectRoot(projectPath: String): Boolean { val projectRoot = File(projectPath) if (!projectRoot.exists()) return false currentProjectRoot = projectRoot - val projectData = ProjectData(context) - val projectTree = projectData.showProjectTree(projectRoot) + val projectTree = ProjectData(context).showProjectTree(projectRoot) currentProjectTree = projectTree currentAgent?.setProjectData(projectTree) permissionManager.addAllowedDirectory(projectRoot.absolutePath) @@ -124,17 +102,31 @@ class AIAgentManager(private val context: Context) { fun clearConversation() { currentAgent?.clearConversation() } + private fun collectTools(): List { + val profile = AgentPromptProfileStore.getActive() ?: return emptyList() + val defs = mutableListOf() + if (profile.enableTools) { + val builtIn = BuiltInToolExecutor(context, currentProjectTree, currentProjectRoot, profile.allowCommandExecution, profile.disabledTools.toSet()) + defs += builtIn.definitions() + } + if (profile.enableMcpTools) { + try { + val servers = McpServerStore.load().filter { s -> profile.enabledMcpServerIds.isEmpty() || profile.enabledMcpServerIds.contains(s.id) } + defs += McpToolExecutor(servers).definitions().filterNot { it.name in profile.disabledTools.toSet() } + } catch (e: Exception) { android.util.Log.w("AIAgentManager", "MCP tool discovery failed", e) } + } + return defs.distinctBy { it.name } + } + private fun executeToolCall(name: String, args: org.json.JSONObject): String { val profile = AgentPromptProfileStore.getActive() if (name.startsWith("mcp__")) { - if (profile?.enableMcpTools == false) return "Error: MCP tools disabled by active agent profile" - val servers = McpServerStore.load().filter { s -> - profile?.enabledMcpServerIds.isNullOrEmpty() || profile!!.enabledMcpServerIds.contains(s.id) - } + if (profile?.enableMcpTools == false) return "Error: MCP tools disabled" + val servers = McpServerStore.load().filter { s -> profile?.enabledMcpServerIds.isNullOrEmpty() || profile!!.enabledMcpServerIds.contains(s.id) } return McpToolExecutor(servers).execute(name, args) } - if (profile?.enableTools == false) return "Error: built-in tools disabled by active agent profile" - val builtIn = BuiltInToolExecutor(context, currentProjectTree, currentProjectRoot, profile?.allowCommandExecution == true) + if (profile?.enableTools == false) return "Error: built-in tools disabled" + val builtIn = BuiltInToolExecutor(context, currentProjectTree, currentProjectRoot, profile?.allowCommandExecution == true, profile?.disabledTools?.toSet() ?: emptySet()) return builtIn.execute(name, args) } @@ -147,33 +139,45 @@ class AIAgentManager(private val context: Context) { while (!success && (currentAgent?.canRetry() == true)) { try { val currentAttempt = currentAgent?.getCurrentAttemptCount() ?: 0 - if (currentAttempt > 0 && !providerSwitched) { - callback.onRetry(currentAttempt, "Thinking differently...") - delay(1000) - } - + if (currentAttempt > 0 && !providerSwitched) { callback.onRetry(currentAttempt, "Thinking differently..."); delay(1000) } val previousFileStates = captureCurrentFileStates() - var promptForModel = userRequest + val agent = currentAgent ?: throw Exception("No agent initialized") + val tools = collectTools() + val profile = AgentPromptProfileStore.getActive() + val toolsEnabled = (profile?.enableTools != false || profile?.enableMcpTools != false) && tools.isNotEmpty() + val maxToolIterations = 8 var toolIterations = 0 - val maxToolIterations = 6 var finalResponse: String? = null - while (toolIterations < maxToolIterations) { - val result = currentAgent?.generateCode(promptForModel, null, "kotlin", null) - ?: Result.failure(Exception("No agent initialized")) - - val response = result.getOrElse { throw it } - val toolCall = ToolCallProtocol.parse(response) - if (toolCall != null) { - toolIterations++ - callback.onProcessing("Using tool: ${toolCall.name} (${toolIterations}/$maxToolIterations)") - val toolResult = try { executeToolCall(toolCall.name, toolCall.arguments) } catch (e: Exception) { "Error: ${e.message}" } - val clipped = if (toolResult.length > 8000) toolResult.take(8000) + "\n...[truncated]" else toolResult - promptForModel = "TOOL_RESULT for ${toolCall.name}:\n$clipped\n\nContinue with the user's request. If you need another tool, emit TOOL_CALL. Otherwise provide the final answer and use FILE_TO_MODIFY for any file edits." - continue + if (toolsEnabled && agent.supportsNativeTools()) { + var res = agent.generateWithTools(userRequest, null, "kotlin", null, tools).getOrElse { throw it } + while (res.hasToolCalls() && toolIterations < maxToolIterations) { + val results = res.toolCalls.map { tc -> + toolIterations++ + callback.onProcessing("Using tool: ${tc.name} (${toolIterations}/$maxToolIterations)") + val out = try { executeToolCall(tc.name, tc.arguments) } catch (e: Exception) { "Error: ${e.message}" } + val clipped = if (out.length > 8000) out.take(8000) + "\n...[truncated]" else out + AIToolResult(tc.name, clipped, tc.id) + } + res = agent.submitToolResults(results, tools).getOrElse { throw it } + } + finalResponse = if (res.text.isNotBlank()) res.text else if (res.hasToolCalls()) "Tool iteration limit reached." else "" + } else { + var promptForModel = userRequest + while (toolIterations < maxToolIterations) { + val response = agent.generateCode(promptForModel, null, "kotlin", null).getOrElse { throw it } + val toolCall = if (toolsEnabled) ToolCallProtocol.parse(response) else null + if (toolCall != null) { + toolIterations++ + callback.onProcessing("Using tool: ${toolCall.name} (${toolIterations}/$maxToolIterations)") + val out = try { executeToolCall(toolCall.name, toolCall.arguments) } catch (e: Exception) { "Error: ${e.message}" } + val clipped = if (out.length > 8000) out.take(8000) + "\n...[truncated]" else out + promptForModel = "TOOL_RESULT for ${toolCall.name}:\n$clipped\n\nContinue. If you need another tool, emit TOOL_CALL. Otherwise provide the final answer and use FILE_TO_MODIFY for file edits." + continue + } + finalResponse = response + break } - finalResponse = response - break } val response = finalResponse ?: "Tool iteration limit reached. Please try a more specific request." @@ -183,28 +187,12 @@ class AIAgentManager(private val context: Context) { if (modifications.isNotEmpty()) { val allSuccessful = modifications.all { it.writeResult is FileWriteResult.Success } if (allSuccessful) { - val results = modifications.map { mod -> - val isNewFile = !previousFileStates.containsKey(mod.filePath) - ModificationResult(mod.filePath, mod.content, true, "Modified successfully", isNewFile) - } - callback.onSuccess(response, results, createSummary(results)) - success = true - } else { - callback.onProcessing("Some files failed. Retrying...") - currentAgent?.incrementAttemptCount() - delay(1500) - } - } else { - callback.onProcessing("No files were modified. Retrying...") - currentAgent?.incrementAttemptCount() - delay(1500) - } - } else { - callback.onTextResponse(response, ModificationSummary(0, 0, 0, 0, 0, emptyList())) - success = true - } + val results = modifications.map { mod -> ModificationResult(mod.filePath, mod.content, true, "Modified successfully", !previousFileStates.containsKey(mod.filePath)) } + callback.onSuccess(response, results, createSummary(results)); success = true + } else { callback.onProcessing("Some files failed. Retrying..."); currentAgent?.incrementAttemptCount(); delay(1500) } + } else { callback.onProcessing("No files were modified. Retrying..."); currentAgent?.incrementAttemptCount(); delay(1500) } + } else { callback.onTextResponse(response, ModificationSummary(0, 0, 0, 0, 0, emptyList())); success = true } } catch (error: Exception) { - android.util.Log.e("AIAgentManager", "Error occurred: ${error.message}", error) val shouldSwitchProvider = error is com.tom.rv2ide.artificial.exceptions.RateLimitException || error is com.tom.rv2ide.artificial.exceptions.QuotaExceededException || error is com.tom.rv2ide.artificial.exceptions.InsufficientBalanceException || @@ -212,53 +200,27 @@ class AIAgentManager(private val context: Context) { if (shouldSwitchProvider && !providerSwitched) { if (providerSwitchDialog.isAutoSwitchEnabled()) { val alternativeProvider = getAlternativeProvider() - if (alternativeProvider != null && setProvider(alternativeProvider)) { - providerSwitched = true - currentAgent?.resetAttemptCount() - callback.onProcessing("āœ… Switched to ${currentAgent?.providerName ?: "Unknown"}") - } else { - callback.onError("${formatErrorMessage(error)}\n\nāŒ Failed to switch providers.") - success = true - } - } else { - callback.onError("PROVIDER_SWITCH_REQUIRED::${formatErrorMessage(error)}") - success = true - } + if (alternativeProvider != null && setProvider(alternativeProvider)) { providerSwitched = true; currentAgent?.resetAttemptCount(); callback.onProcessing("āœ… Switched to ${currentAgent?.providerName ?: "Unknown"}") } + else { callback.onError("${formatErrorMessage(error)}\n\nāŒ Failed to switch providers."); success = true } + } else { callback.onError("PROVIDER_SWITCH_REQUIRED::${formatErrorMessage(error)}"); success = true } } else if ((currentAgent?.canRetry() == true) && !providerSwitched) { - callback.onRetry(currentAgent?.getCurrentAttemptCount() ?: 0, "Error: ${error.message?.take(50) ?: "Unknown error"}. Retrying...") - currentAgent?.incrementAttemptCount() - delay(1500) - } else { - callback.onError(formatErrorMessage(error)) - success = true - } + callback.onRetry(currentAgent?.getCurrentAttemptCount() ?: 0, "Error: ${error.message?.take(50) ?: "Unknown error"}. Retrying..."); currentAgent?.incrementAttemptCount(); delay(1500) + } else { callback.onError(formatErrorMessage(error)); success = true } } } - - if (!success) { - callback.onError("Failed after ${currentAgent?.getCurrentAttemptCount() ?: 0} attempts with ${currentAgent?.providerName ?: "No agent initialized"}.\n\nPlease check your configuration and try again.") - undoLastModification() - } + if (!success) { callback.onError("Failed after ${currentAgent?.getCurrentAttemptCount() ?: 0} attempts with ${currentAgent?.providerName ?: "No agent initialized"}."); undoLastModification() } } private fun getAlternativeProvider(): String? = AIAgentRegistry.getAvailableProviders().firstOrNull { it != currentProviderId } - private suspend fun processModifications( - response: String, - previousFileStates: Map, - callback: AIAgentCallback - ): List { + private suspend fun processModifications(response: String, previousFileStates: Map, callback: AIAgentCallback): List { val modifications = mutableListOf() val parser = SnippetParser() if (response.contains("FILE_TO_MODIFY:")) { - val lines = response.lines() - var currentFile: String? = null - val contentBuilder = StringBuilder() - var inContent = false + val lines = response.lines(); var currentFile: String? = null; val contentBuilder = StringBuilder(); var inContent = false fun flush(file: String?, content: StringBuilder) { if (file != null && content.isNotEmpty()) { - val fileName = File(file).name - callback.onFileModifying(file, fileName) + val fileName = File(file).name; callback.onFileModifying(file, fileName) val cleanedContent = parser.cleanFileContent(content.toString().trim()) val previousContent = previousFileStates[file] val writeResult = currentAgent?.writeFile(file, cleanedContent) ?: FileWriteResult.Error("No agent initialized") @@ -269,12 +231,8 @@ class AIAgentManager(private val context: Context) { } } for (line in lines) { - if (line.startsWith("FILE_TO_MODIFY:")) { - flush(currentFile, contentBuilder) - currentFile = line.substringAfter("FILE_TO_MODIFY:").trim() - contentBuilder.clear() - inContent = true - } else if (inContent) contentBuilder.append(line).append("\n") + if (line.startsWith("FILE_TO_MODIFY:")) { flush(currentFile, contentBuilder); currentFile = line.substringAfter("FILE_TO_MODIFY:").trim(); contentBuilder.clear(); inContent = true } + else if (inContent) contentBuilder.append(line).append("\n") } flush(currentFile, contentBuilder) } @@ -301,47 +259,17 @@ class AIAgentManager(private val context: Context) { val availableProviders = getAvailableProviders().filter { it.id != currentProviderId && it.isAvailable }.map { Pair(it.id, it.name) } providerSwitchDialog.showProviderErrorDialog(currentProviderName, errorMessage, availableProviders, onProviderSelected = { providerId -> - setProvider(providerId) - val agents = Agents(context) - val availableModels = agents.getModelsForProvider(providerId) - if (availableModels.isNotEmpty()) agents.setAgent(availableModels[0]) - reinitializeWithSelectedModel() - onProviderSelected(providerId) + setProvider(providerId); val agents = Agents(context); val availableModels = agents.getModelsForProvider(providerId) + if (availableModels.isNotEmpty()) agents.setAgent(availableModels[0]); reinitializeWithSelectedModel(); onProviderSelected(providerId) }, - onEnableAutoSwitch = { - getAlternativeProvider()?.let { - setProvider(it) - val agents = Agents(context) - val availableModels = agents.getModelsForProvider(it) - if (availableModels.isNotEmpty()) agents.setAgent(availableModels[0]) - reinitializeWithSelectedModel() - } - } + onEnableAutoSwitch = { getAlternativeProvider()?.let { setProvider(it); val agents = Agents(context); val availableModels = agents.getModelsForProvider(it); if (availableModels.isNotEmpty()) agents.setAgent(availableModels[0]); reinitializeWithSelectedModel() } } ) } fun isAutoSwitchEnabled(): Boolean = providerSwitchDialog.isAutoSwitchEnabled() fun setAutoSwitch(enabled: Boolean) = providerSwitchDialog.setAutoSwitch(enabled) - - private fun createSummary(results: List): ModificationSummary { - val successful = results.count { it.success } - val failed = results.count { !it.success } - val newFiles = results.count { it.isNewFile } - val modifiedFiles = results.count { !it.isNewFile } - val fileDetails = results.map { FileDetail(File(it.filePath).name, it.filePath, if (it.success) FileStatus.SUCCESS else FileStatus.FAILED, if (it.isNewFile) ChangeType.CREATED else ChangeType.MODIFIED) } - return ModificationSummary(results.size, successful, failed, newFiles, modifiedFiles, fileDetails) - } - - private fun captureCurrentFileStates(): Map { - val states = mutableMapOf() - val projectRoot = currentProjectRoot ?: return states - if (!projectRoot.exists()) return states - projectRoot.walkTopDown().filter { it.isFile }.filter { it.extension in listOf("kt","java","xml","gradle","kts") && !it.path.contains("/build/") && !it.path.contains("/.gradle/") }.forEach { file -> - try { states[file.absolutePath] = file.readText() } catch (_: Exception) {} - } - return states - } - + private fun createSummary(results: List): ModificationSummary = ModificationSummary(results.size, results.count { it.success }, results.count { !it.success }, results.count { it.isNewFile }, results.count { !it.isNewFile }, results.map { FileDetail(File(it.filePath).name, it.filePath, if (it.success) FileStatus.SUCCESS else FileStatus.FAILED, if (it.isNewFile) ChangeType.CREATED else ChangeType.MODIFIED) }) + private fun captureCurrentFileStates(): Map { val states = mutableMapOf(); val projectRoot = currentProjectRoot ?: return states; if (!projectRoot.exists()) return states; projectRoot.walkTopDown().filter { it.isFile }.filter { it.extension in listOf("kt","java","xml","gradle","kts") && !it.path.contains("/build/") && !it.path.contains("/.gradle/") }.forEach { file -> try { states[file.absolutePath] = file.readText() } catch (_: Exception) {} }; return states } fun undoLastModification(): Boolean = currentAgent?.undoLastModification() ?: false fun reinitializeWithSelectedModel() { AIAgentRegistry.getFactory(currentProviderId)?.getApiKey()?.let { currentAgent?.reinitializeWithNewModel(it, context) } } fun getCurrentModelName(): String = Agents(context).getAgent() @@ -356,7 +284,6 @@ class AIAgentManager(private val context: Context) { fun onError(message: String) fun onRetry(attemptNumber: Int, message: String) } - data class ModificationResult(val filePath: String, val content: String, val success: Boolean, val message: String, val isNewFile: Boolean = false) data class ModificationSummary(val totalFiles: Int, val successfulFiles: Int, val failedFiles: Int, val newFiles: Int, val modifiedFiles: Int, val fileDetails: List) data class FileDetail(val fileName: String, val filePath: String, val status: FileStatus, val changeType: ChangeType) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt index 9aed9e0055..308461c31a 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt @@ -28,8 +28,12 @@ import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore import com.tom.rv2ide.artificial.profiles.CustomProviderProfileStore import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult import com.tom.rv2ide.artificial.rules.WritingRules -import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol import com.tom.rv2ide.artificial.mcp.McpServerStore import com.tom.rv2ide.artificial.mcp.McpToolExecutor import java.io.File @@ -43,20 +47,11 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject -/* - * Custom OpenAI-compatible provider support with profiles, tools, and MCP. - */ class CustomProvider : AIAgent { - private var baseUrl: String? = null private var modelName: String? = null private var apiKey: String? = null - private val httpClient = - OkHttpClient.Builder() - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(120, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .build() + private val httpClient = OkHttpClient.Builder().connectTimeout(60, TimeUnit.SECONDS).readTimeout(120, TimeUnit.SECONDS).writeTimeout(60, TimeUnit.SECONDS).build() private val writingRules = WritingRules.Instructions() private var projectTreeResult: ProjectTreeResult? = null private var fileWriter: AIFileWriter? = null @@ -64,191 +59,176 @@ class CustomProvider : AIAgent { private val modificationHistory = mutableListOf() private var currentAttemptCount = 0 private val maxRetryAttempts = 3 - private var agents: Agents? = null private var appContext: Context? = null + private val nativeMessages = JSONArray() + private var nativeToolsActive = false override val providerId = "custom" override val providerName = "Custom Provider" companion object { fun registerAgent() { - AIAgentRegistry.register( - "custom", - object : AIAgentRegistry.AgentFactory { - override fun create(context: Context): AIAgent = CustomProvider() - override fun hasValidApiKey(): Boolean { - val p = CustomProviderProfileStore.getActive() - return p != null && p.baseUrl.isNotBlank() && p.modelName.isNotBlank() - } - override fun getApiKey(): String? { - val p = CustomProviderProfileStore.getActive() ?: return null - return if (p.apiKey.isBlank()) "custom" else p.apiKey - } - }) + AIAgentRegistry.register("custom", object : AIAgentRegistry.AgentFactory { + override fun create(context: Context): AIAgent = CustomProvider() + override fun hasValidApiKey(): Boolean { val p = CustomProviderProfileStore.getActive(); return p != null && p.baseUrl.isNotBlank() && p.modelName.isNotBlank() } + override fun getApiKey(): String? { val p = CustomProviderProfileStore.getActive() ?: return null; return if (p.apiKey.isBlank()) "custom" else p.apiKey } + }) } - private fun buildEndpoint(baseUrl: String): String { val trimmed = baseUrl.trim().trimEnd('/') - return when { - trimmed.contains("/chat/completions") -> trimmed - trimmed.endsWith("/v1") -> "$trimmed/chat/completions" - else -> "$trimmed/v1/chat/completions" - } + return when { trimmed.contains("/chat/completions") -> trimmed; trimmed.endsWith("/v1") -> "$trimmed/chat/completions"; else -> "$trimmed/v1/chat/completions" } } } override fun initialize(apiKey: String, context: Context) { appContext = context.applicationContext - agents = Agents(context) val profile = CustomProviderProfileStore.getActive() - baseUrl = profile?.baseUrl - modelName = profile?.modelName - this.apiKey = profile?.apiKey - if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) { - throw IllegalStateException("Custom provider not configured. Please add/select a custom provider profile.") - } + baseUrl = profile?.baseUrl; modelName = profile?.modelName; this.apiKey = profile?.apiKey + if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) throw IllegalStateException("Custom provider not configured. Add/select a custom provider profile.") } - override fun isInitialized(): Boolean = !baseUrl.isNullOrEmpty() && !modelName.isNullOrEmpty() - override fun reinitializeWithNewModel(apiKey: String, context: Context) { initialize(apiKey, context) } override fun setContext(context: Context) { appContext = context.applicationContext; fileWriter = AIFileWriter(context) } override fun setProjectData(projectTreeResult: ProjectTreeResult) { this.projectTreeResult = projectTreeResult } - override fun clearConversation() { conversationHistory.clear(); modificationHistory.clear(); currentAttemptCount = 0 } - - override fun recordModification(filePath: String, oldContent: String?, newContent: String, success: Boolean) { - modificationHistory.add(ModificationAttempt(System.currentTimeMillis(), filePath, oldContent, newContent, currentAttemptCount, success)) - } - + override fun clearConversation() { conversationHistory.clear(); modificationHistory.clear(); currentAttemptCount = 0; clearNative() } + override fun recordModification(filePath: String, oldContent: String?, newContent: String, success: Boolean) { modificationHistory.add(ModificationAttempt(System.currentTimeMillis(), filePath, oldContent, newContent, currentAttemptCount, success)) } override fun undoLastModification(): Boolean { if (modificationHistory.isEmpty()) return false val lastMod = modificationHistory.lastOrNull { it.success } ?: return false - if (lastMod.previousContent != null) { - val result = writeFile(lastMod.filePath, lastMod.previousContent) - if (result is FileWriteResult.Success) { modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)); return true } - } else { - try { File(lastMod.filePath).delete(); modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)); return true } catch (_: Exception) {} - } + if (lastMod.previousContent != null) { val result = writeFile(lastMod.filePath, lastMod.previousContent); if (result is FileWriteResult.Success) { modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)); return true } } + else { try { File(lastMod.filePath).delete(); modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)); return true } catch (_: Exception) {} } return false } - override fun getModificationHistory(): List = modificationHistory.toList() override fun resetAttemptCount() { currentAttemptCount = 0 } override fun incrementAttemptCount() { currentAttemptCount++ } override fun getCurrentAttemptCount(): Int = currentAttemptCount override fun canRetry(): Boolean = currentAttemptCount < maxRetryAttempts - private fun isUserRequestingCorrection(message: String): Boolean { - val correctionKeywords = listOf("wrong","not what","mistake","error","incorrect","that's not","not right","fix","undo","revert","different","try again","not working") - return correctionKeywords.any { message.lowercase().contains(it) } - } - - private fun composeSystemPrompt(context: Context): String { - val base = writingRules.useThis() + private fun isUserRequestingCorrection(message: String): Boolean = listOf("wrong","not what","mistake","error","incorrect","that's not","not right","fix","undo","revert","different","try again","not working").any { message.lowercase().contains(it) } + private fun composeSystemPrompt(context: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) val agentProfile = AgentPromptProfileStore.getActive() - val sb = StringBuilder(base) val extra = agentProfile?.systemPrompt?.trim().orEmpty() if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") - if (agentProfile?.enableTools != false) { - val builtIn = BuiltInToolExecutor(context, projectTreeResult, null, agentProfile?.allowCommandExecution == true) - val mcpDefs = if (agentProfile?.enableMcpTools != false) { - try { - val servers = McpServerStore.load().filter { s -> - agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) - } - McpToolExecutor(servers).definitions() - } catch (_: Exception) { emptyList() } - } else emptyList() + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(context, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) } return sb.toString() } - override suspend fun generateCode( - prompt: String, - context: String?, - language: String, - projectStructure: String?, - ): Result = withContext(Dispatchers.IO) { + private fun buildUserPrompt(prompt: String, context: String?): String { + val fileContents = readRelevantFiles() + val needsCorrection = isUserRequestingCorrection(prompt) + return buildString { + append("=== PROJECT STRUCTURE (THESE ARE THE EXACT PATHS YOU MUST USE) ===\n") + if (projectTreeResult != null) { append(projectTreeResult!!.tree); append("\n\nCRITICAL: Use ONLY the paths shown above. Do NOT make up fake paths.\n\n") } + if (fileContents.isNotEmpty()) { append("=== CURRENT FILES CONTENT ===\n"); fileContents.forEach { (path, content) -> append("FILE: $path\nCONTENT:\n").append(content).append("\n\n") } } + if (context != null) append("=== ADDITIONAL CONTEXT ===\n").append(context).append("\n\n") + if (conversationHistory.isNotEmpty()) { append("=== CONVERSATION HISTORY ===\n"); conversationHistory.takeLast(8).forEach { msg -> append("${msg.role.uppercase()}: ${msg.content.take(3000)}\n\n") } } + if (needsCorrection && modificationHistory.isNotEmpty()) { append("=== CORRECTION REQUIRED ===\nTry a COMPLETELY DIFFERENT approach.\n\n") } + if (currentAttemptCount > 0) append("=== RETRY ATTEMPT $currentAttemptCount/$maxRetryAttempts ===\n\n") + append("=== USER REQUEST ===\n").append(prompt) + } + } + + override suspend fun generateCode(prompt: String, context: String?, language: String, projectStructure: String?): Result = withContext(Dispatchers.IO) { try { - if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) return@withContext Result.failure(IllegalStateException("Custom provider not configured")) val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) - val fileContents = readRelevantFiles() - val needsCorrection = isUserRequestingCorrection(prompt) - val fullPrompt = buildString { - append("=== PROJECT STRUCTURE (THESE ARE THE EXACT PATHS YOU MUST USE) ===\n") - if (projectTreeResult != null) { - append(projectTreeResult!!.tree) - append("\n\nCRITICAL: Use ONLY the paths shown above. Do NOT make up fake paths.\nCRITICAL: Look at the actual paths above and use those EXACT paths.\n\n") - } - if (fileContents.isNotEmpty()) { - append("=== CURRENT FILES CONTENT ===\n") - fileContents.forEach { (path, content) -> append("FILE: $path\nCONTENT:\n").append(content).append("\n\n") } - } - if (context != null) append("=== ADDITIONAL CONTEXT ===\n").append(context).append("\n\n") - if (conversationHistory.isNotEmpty()) { - append("=== CONVERSATION HISTORY ===\n") - conversationHistory.takeLast(12).forEach { msg -> append("${msg.role.uppercase()}: ${msg.content.take(4000)}\n\n") } - } - if (needsCorrection && modificationHistory.isNotEmpty()) { - append("=== CORRECTION REQUIRED ===\nPrevious failed attempts:\n") - modificationHistory.takeLast(3).forEach { attempt -> append("Attempt ${attempt.attemptNumber}: ${attempt.filePath} - ${if (attempt.success) "Applied but user rejected" else "Failed"}\n") } - append("Provide a COMPLETELY DIFFERENT approach.\n\n") - } - if (currentAttemptCount > 0) append("=== RETRY ATTEMPT $currentAttemptCount/$maxRetryAttempts ===\nTry a different solution.\n\n") - append("=== USER REQUEST ===\n").append(prompt) - } + clearNative() + val response = callChat(buildMessages(composeSystemPrompt(ctx, includeTextToolDefs = true), buildUserPrompt(prompt, context)), tools = null) + conversationHistory.add(ConversationMessage("user", prompt)); conversationHistory.add(ConversationMessage("assistant", response.text)) + while (conversationHistory.size > 20) conversationHistory.removeAt(0) + Result.success(response.text) + } catch (e: Exception) { Result.failure(e) } + } - val messages = JSONArray() - messages.put(JSONObject().apply { put("role", "system"); put("content", composeSystemPrompt(ctx)) }) - messages.put(JSONObject().apply { put("role", "user"); put("content", fullPrompt) }) + override fun supportsNativeTools(): Boolean = true - val requestBody = JSONObject().apply { put("model", modelName); put("messages", messages); put("temperature", 0.7); put("stream", false) } - val endpoint = buildEndpoint(baseUrl!!) - val requestBuilder = Request.Builder().url(endpoint).post(requestBody.toString().toRequestBody("application/json".toMediaType())).addHeader("Content-Type", "application/json") - val key = apiKey?.trim() - if (!key.isNullOrEmpty()) requestBuilder.addHeader("Authorization", "Bearer $key") + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + clearNative(); nativeToolsActive = true + nativeMessages.put(JSONObject().put("role", "system").put("content", composeSystemPrompt(ctx, includeTextToolDefs = false))) + nativeMessages.put(JSONObject().put("role", "user").put("content", buildUserPrompt(prompt, context))) + val res = callChat(nativeMessages, tools) + if (res.hasToolCalls()) nativeMessages.put(assistantToolCallMessage(res)) else nativeMessages.put(JSONObject().put("role","assistant").put("content", res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } - val response = try { httpClient.newCall(requestBuilder.build()).execute() } catch (e: Exception) { - val errorMessage = e.message ?: "" - if (errorMessage.contains("timeout") || errorMessage.contains("connect")) throw com.tom.rv2ide.artificial.exceptions.RateLimitException("Connection timeout. Please check your custom provider endpoint.") - throw e - } - if (!response.isSuccessful) { - val errorBody = response.body?.string() ?: "Unknown error" - return@withContext Result.failure(Exception("Custom provider error ${response.code}: $errorBody")) + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { + if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n") { it.result }, null, "kotlin", null, tools) + toolResults.forEach { tr -> + nativeMessages.put(JSONObject().apply { + put("role", "tool") + put("tool_call_id", tr.toolCallId ?: tr.name) + put("content", tr.result) + }) } - val responseBody = response.body?.string() ?: "" - val jsonResponse = JSONObject(responseBody) - val generatedResponse = jsonResponse.getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content") - if (generatedResponse.isBlank()) return@withContext Result.failure(Exception("Empty response from custom provider")) - - conversationHistory.add(ConversationMessage("user", prompt)) - conversationHistory.add(ConversationMessage("assistant", generatedResponse)) - while (conversationHistory.size > 20) { conversationHistory.removeAt(0) } - Result.success(generatedResponse) - } catch (e: com.tom.rv2ide.artificial.exceptions.RateLimitException) { Result.failure(e) - } catch (e: com.tom.rv2ide.artificial.exceptions.QuotaExceededException) { Result.failure(e) - } catch (e: com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException) { Result.failure(e) + val res = callChat(nativeMessages, tools) + if (res.hasToolCalls()) nativeMessages.put(assistantToolCallMessage(res)) else nativeMessages.put(JSONObject().put("role","assistant").put("content", res.text)) + Result.success(res) } catch (e: Exception) { Result.failure(e) } } + private fun assistantToolCallMessage(res: ToolAwareResponse): JSONObject = JSONObject().apply { + put("role", "assistant"); put("content", res.text) + val arr = JSONArray() + res.toolCalls.forEach { tc -> + arr.put(JSONObject().apply { + put("id", tc.id ?: "call_${tc.name}_${System.nanoTime()}") + put("type", "function") + put("function", JSONObject().apply { put("name", tc.name); put("arguments", tc.arguments.toString()) }) + }) + } + put("tool_calls", arr) + } + + private fun clearNative() { nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) } + private fun buildMessages(system: String, user: String): JSONArray = JSONArray().apply { put(JSONObject().put("role","system").put("content", system)); put(JSONObject().put("role","user").put("content", user)) } + + private fun callChat(messages: JSONArray, tools: List?): ToolAwareResponse { + if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) throw IllegalStateException("Custom provider not configured") + val requestBody = JSONObject().apply { + put("model", modelName); put("messages", messages); put("temperature", 0.7); put("stream", false) + if (!tools.isNullOrEmpty()) { put("tools", ToolCallProtocol.openAIToolsJson(tools)); put("tool_choice", "auto") } + } + val requestBuilder = Request.Builder().url(buildEndpoint(baseUrl!!)).post(requestBody.toString().toRequestBody("application/json".toMediaType())).addHeader("Content-Type", "application/json") + val key = apiKey?.trim(); if (!key.isNullOrEmpty()) requestBuilder.addHeader("Authorization", "Bearer $key") + val response = try { httpClient.newCall(requestBuilder.build()).execute() } catch (e: Exception) { throw Exception("Connection error: ${e.message}", e) } + if (!response.isSuccessful) { val err = response.body?.string() ?: "Unknown error"; throw Exception("Custom provider error ${response.code}: $err") } + val body = response.body?.string().orEmpty(); if (body.isBlank()) throw Exception("Empty response from custom provider") + val json = JSONObject(body); val msg = json.getJSONArray("choices").getJSONObject(0).getJSONObject("message") + val text = msg.optString("content", "") + val toolCallsArr = msg.optJSONArray("tool_calls") + val calls = mutableListOf() + if (toolCallsArr != null) { + for (i in 0 until toolCallsArr.length()) { + val tc = toolCallsArr.optJSONObject(i) ?: continue + val fn = tc.optJSONObject("function") ?: continue + val name = fn.optString("name"); if (name.isBlank()) continue + val argsStr = fn.optString("arguments", "{}") + val args = try { JSONObject(if (argsStr.isBlank()) "{}" else argsStr) } catch (_: Exception) { JSONObject() } + calls.add(AIToolCall(name, args, tc.toString(), tc.optString("id").ifBlank { null })) + } + } + if (calls.isEmpty()) { val parsed = ToolCallProtocol.parse(text); if (parsed != null) calls.add(parsed) } + return ToolAwareResponse(text = text, toolCalls = calls) + } + private fun readRelevantFiles(): Map { - val filesContent = mutableMapOf() - val tree = projectTreeResult?.tree ?: return filesContent + val filesContent = mutableMapOf(); val tree = projectTreeResult?.tree ?: return filesContent tree.lines().filter { it.isNotBlank() }.forEach { filePath -> - val trimmedPath = filePath.trim() - val file = File(trimmedPath) + val trimmedPath = filePath.trim(); val file = File(trimmedPath) if (file.isFile && (trimmedPath.endsWith(".kt") || trimmedPath.endsWith(".java") || trimmedPath.endsWith(".xml") || trimmedPath.endsWith(".gradle") || trimmedPath.endsWith(".gradle.kts")) && !trimmedPath.contains("/build/") && !trimmedPath.contains("/.gradle/")) { try { filesContent[trimmedPath] = file.readText() } catch (_: Exception) {} } } return filesContent } - - override fun writeFile(filePath: String, content: String): FileWriteResult { - val writer = fileWriter ?: return FileWriteResult.Error("File writer not initialized") - return writer.writeFile(filePath, content, createBackup = true) - } + override fun writeFile(filePath: String, content: String): FileWriteResult { val writer = fileWriter ?: return FileWriteResult.Error("File writer not initialized"); return writer.writeFile(filePath, content, createBackup = true) } } - data class ConversationMessage(val role: String, val content: String) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/AgentProfilesDialog.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/AgentProfilesDialog.kt index 8242edbe6e..47912f05fa 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/AgentProfilesDialog.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/AgentProfilesDialog.kt @@ -47,19 +47,41 @@ object AgentProfilesDialog { private fun selectOrEdit(context: Context, profile: AgentPromptProfile, onChanged: () -> Unit) { MaterialAlertDialogBuilder(context) .setTitle(profile.name) - .setItems(arrayOf("Activate", "Edit", "Duplicate", "Delete")) { d, which -> + .setItems(arrayOf("Activate", "Edit", "Tools/Capabilities", "Duplicate", "Delete")) { d, which -> d.dismiss() when (which) { 0 -> { AgentPromptProfileStore.setActive(profile.id); Toast.makeText(context, "Activated", Toast.LENGTH_SHORT).show(); onChanged() } 1 -> edit(context, profile, onChanged) - 2 -> { AgentPromptProfileStore.upsert(profile.copy(id = java.util.UUID.randomUUID().toString(), name = profile.name + " (copy)")); onChanged(); show(context, onChanged) } - 3 -> { AgentPromptProfileStore.delete(profile.id); onChanged(); show(context, onChanged) } + 2 -> editTools(context, profile, onChanged) + 3 -> { AgentPromptProfileStore.upsert(profile.copy(id = java.util.UUID.randomUUID().toString(), name = profile.name + " (copy)")); onChanged(); show(context, onChanged) } + 4 -> { AgentPromptProfileStore.delete(profile.id); onChanged(); show(context, onChanged) } } } .setNegativeButton("Back") { _, _ -> show(context, onChanged) } .show() } + private fun editTools(context: Context, profile: AgentPromptProfile, onChanged: () -> Unit) { + val tools = SkillPresets.knownToolNames() + val checked = tools.map { it !in profile.disabledTools }.toBooleanArray() + val labels = tools.map { + when (it) { + "run_command" -> "run_command (risky, also requires opt-in)" + else -> it + } + }.toTypedArray() + MaterialAlertDialogBuilder(context) + .setTitle("Tools/Capabilities: ${profile.name}") + .setMultiChoiceItems(labels, checked) { _, which, isChecked -> checked[which] = isChecked } + .setPositiveButton("Save") { _, _ -> + val disabled = tools.filterIndexed { i, _ -> !checked[i] } + AgentPromptProfileStore.upsert(profile.copy(disabledTools = disabled)) + onChanged(); selectOrEdit(context, AgentPromptProfileStore.load().second.firstOrNull { it.id == profile.id } ?: profile, onChanged) + } + .setNegativeButton("Cancel") { _, _ -> selectOrEdit(context, profile, onChanged) } + .show() + } + private fun edit(context: Context, existing: AgentPromptProfile?, onChanged: () -> Unit) { val container = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL; setPadding(48, 24, 48, 0) } val nameInput = TextInputEditText(context).apply { setText(existing?.name ?: "New Profile") } diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt index 7e1cb006d5..ab8c8b55d3 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt @@ -1,29 +1,28 @@ package com.tom.rv2ide.artificial.mcp -import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolDef import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONArray import org.json.JSONObject /** - * Minimal MCP client (HTTP JSON-RPC). Intended for remote/HTTP MCP servers. - * Supports initialize, tools/list, tools/call. Some servers use Streamable HTTP - * semantics; this implementation uses plain JSON-RPC POST requests for broad - * compatibility and surfaces errors to the agent. + * MCP client with HTTP JSON-RPC + basic Streamable HTTP compatibility. + * Handles session IDs (Mcp-Session-Id), SSE/JSON responses, and common header patterns. */ class McpClient(private val config: McpServerConfig) { private val http = OkHttpClient.Builder() .connectTimeout(20, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) + .readTimeout(90, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() private val nextId = AtomicInteger(1) @Volatile private var initialized = false + @Volatile private var sessionId: String? = null + @Volatile private var protocolVersion: String = "2025-03-26" fun listTools(): List { ensureInitialized() @@ -42,9 +41,7 @@ class McpClient(private val config: McpServerConfig) { fun callTool(toolName: String, arguments: JSONObject): String { ensureInitialized() - val actual = toolName.substringAfterLast("__", toolName).let { - if (toolName.startsWith("mcp__")) toolName.split("__").getOrNull(2) ?: it else toolName - } + val actual = if (toolName.startsWith("mcp__")) toolName.split("__").getOrNull(2) ?: toolName.substringAfterLast("__") else toolName val params = JSONObject().put("name", actual).put("arguments", arguments) val res = rpc("tools/call", params) ?: return "Error: no response from MCP server" if (res.has("error")) return "MCP error: ${res.opt("error")}" @@ -54,13 +51,18 @@ class McpClient(private val config: McpServerConfig) { val sb = StringBuilder() for (i in 0 until content.length()) { val c = content.optJSONObject(i) ?: continue - if (c.optString("type") == "text") sb.append(c.optString("text")) - else sb.append(c.toString()) + when (c.optString("type")) { + "text" -> sb.append(c.optString("text")) + "resource" -> sb.append(c.optJSONObject("resource")?.optString("text") ?: c.toString()) + else -> sb.append(c.toString()) + } + if (i < content.length() - 1) sb.append("\n") } val text = sb.toString() if (text.isNotBlank()) return text } if (result.has("text")) return result.optString("text") + if (result.has("structuredContent")) return result.opt("structuredContent").toString() return result.toString() } @@ -68,12 +70,12 @@ class McpClient(private val config: McpServerConfig) { private fun ensureInitialized() { if (initialized) return val params = JSONObject().apply { - put("protocolVersion", "2024-11-05") - put("capabilities", JSONObject()) + put("protocolVersion", protocolVersion) + put("capabilities", JSONObject().apply { put("roots", JSONObject().put("listChanged", false)) }) put("clientInfo", JSONObject().put("name", "AndroidCodeStudio").put("version", "1.0")) } - rpc("initialize", params) - // best-effort initialized notification (some servers require/ignore) + val res = rpc("initialize", params) + res?.optJSONObject("result")?.optString("protocolVersion")?.takeIf { it.isNotBlank() }?.let { protocolVersion = it } try { notify("notifications/initialized", JSONObject()) } catch (_: Exception) {} initialized = true } @@ -87,7 +89,7 @@ class McpClient(private val config: McpServerConfig) { private fun notify(method: String, params: JSONObject) { val body = JSONObject().apply { put("jsonrpc", "2.0"); put("method", method); put("params", params) } - post(body) + try { post(body) } catch (_: Exception) {} } private fun post(body: JSONObject): JSONObject? { @@ -95,19 +97,22 @@ class McpClient(private val config: McpServerConfig) { val reqBuilder = Request.Builder() .url(config.url.trim()) .post(body.toString().toRequestBody("application/json".toMediaType())) + .addHeader("Content-Type", "application/json") .addHeader("Accept", "application/json, text/event-stream") + .addHeader("MCP-Protocol-Version", protocolVersion) + sessionId?.let { reqBuilder.addHeader("Mcp-Session-Id", it) } config.headers.forEach { (k, v) -> if (k.isNotBlank()) reqBuilder.addHeader(k, v) } http.newCall(reqBuilder.build()).execute().use { resp -> + resp.header("Mcp-Session-Id")?.takeIf { it.isNotBlank() }?.let { sessionId = it } val text = resp.body?.string().orEmpty() - if (text.isBlank()) return if (resp.isSuccessful) JSONObject() else JSONObject().put("error", "HTTP ${resp.code}") - // handle SSE responses (data: lines) - if (text.startsWith("event:") || text.startsWith("data:") || resp.header("Content-Type")?.contains("text/event-stream") == true) { + if (resp.code == 202 || resp.code == 204 || text.isBlank()) { + return if (resp.isSuccessful) JSONObject().put("result", JSONObject()) else JSONObject().put("error", "HTTP ${resp.code}") + } + val ct = resp.header("Content-Type") ?: "" + if (ct.contains("text/event-stream") || text.startsWith("event:") || text.startsWith("data:")) { val dataLines = text.lines().filter { it.startsWith("data:") }.map { it.removePrefix("data:").trim() }.filter { it.isNotBlank() && it != "[DONE]" } for (line in dataLines.asReversed()) { - try { - val o = JSONObject(line) - if (o.has("result") || o.has("error")) return o - } catch (_: Exception) {} + try { val o = JSONObject(line); if (o.has("result") || o.has("error") || o.has("jsonrpc")) return o } catch (_: Exception) {} } return try { JSONObject(dataLines.lastOrNull() ?: "{}") } catch (_: Exception) { JSONObject().put("error", text.take(500)) } } @@ -123,7 +128,8 @@ class McpToolExecutor(private val servers: List) : com.tom.rv2i override fun definitions(): List = clients.flatMap { (cfg, client) -> try { client.listTools() } catch (e: Exception) { - listOf(AIToolDef("mcp__${cfg.name}__status", "[MCP:${cfg.name}] unavailable: ${e.message}", "{\"type\":\"object\",\"properties\":{}}")) + android.util.Log.w("McpToolExecutor", "Failed to list tools for ${cfg.name}", e) + emptyList() } } diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt index 239fa5ae1c..38a10621e1 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt @@ -13,18 +13,20 @@ data class AgentPromptProfile( val enableMcpTools: Boolean = true, val allowCommandExecution: Boolean = false, val enabledMcpServerIds: List = emptyList(), + val disabledTools: List = emptyList(), val skillPreset: String? = null, ) object SkillPresets { fun all(): List = listOf( - AgentPromptProfile(id = "skill-file-reader", name = "Skill: File Reader", systemPrompt = "Focus on reading and understanding project files. Use read_file/list_files/search_project/get_project_tree before making claims. Prefer citing exact paths and line numbers.", enableTools = true, enableMcpTools = false, skillPreset = "file_reader"), - AgentPromptProfile(id = "skill-command-run", name = "Skill: Command Run (opt-in)", systemPrompt = "You may use run_command only when explicitly enabled and necessary. Prefer safe, read-only commands. Explain what a command does before running it. Never run destructive commands without explicit user confirmation in chat.", enableTools = true, enableMcpTools = false, allowCommandExecution = false, skillPreset = "command_run"), - AgentPromptProfile(id = "skill-file-modification", name = "Skill: File Modification", systemPrompt = "Focus on precise code/file edits. Read relevant files first when needed, then produce FILE_TO_MODIFY blocks with complete, correct file contents. Preserve unrelated code.", enableTools = true, enableMcpTools = false, skillPreset = "file_modification"), + AgentPromptProfile(id = "skill-file-reader", name = "Skill: File Reader", systemPrompt = "Focus on reading and understanding project files. Use read_file/list_files/search_project/get_project_tree before making claims. Prefer citing exact paths and line numbers.", enableTools = true, enableMcpTools = false, disabledTools = listOf("modify_file","run_command"), skillPreset = "file_reader"), + AgentPromptProfile(id = "skill-command-run", name = "Skill: Command Run (opt-in)", systemPrompt = "You may use run_command only when explicitly enabled and necessary. Prefer safe, read-only commands. Explain what a command does before running it.", enableTools = true, enableMcpTools = false, allowCommandExecution = false, skillPreset = "command_run"), + AgentPromptProfile(id = "skill-file-modification", name = "Skill: File Modification", systemPrompt = "Focus on precise code/file edits. Read relevant files first when needed, then produce FILE_TO_MODIFY blocks with complete, correct file contents. Preserve unrelated code.", enableTools = true, enableMcpTools = false, disabledTools = listOf("run_command"), skillPreset = "file_modification"), AgentPromptProfile(id = "skill-stitch", name = "Skill: Stitch", systemPrompt = "You are a UI/product implementation assistant for Stitch-style workflows. Turn requirements into clear implementation steps and Android/UI code changes. Use available MCP/tools if configured. When editing files, use FILE_TO_MODIFY with exact project paths.", enableTools = true, enableMcpTools = true, skillPreset = "stitch"), AgentPromptProfile(id = "skill-design", name = "Skill: Design", systemPrompt = "You are a software design assistant. Produce clear architecture, UX, and implementation guidance. Prefer practical Android/Gradle/Kotlin patterns. When the user asks for file changes, output FILE_TO_MODIFY blocks.", enableTools = true, enableMcpTools = true, skillPreset = "design"), AgentPromptProfile(id = "skill-custom", name = "Skill: Custom", systemPrompt = "", enableTools = true, enableMcpTools = true, skillPreset = "custom"), ) + fun knownToolNames() = listOf("read_file","list_files","search_project","get_project_tree","modify_file","run_command") } object AgentPromptProfileStore { @@ -43,21 +45,18 @@ object AgentPromptProfileStore { for (i in 0 until arr.length()) { val p = arr.optJSONObject(i) ?: continue val name = p.optString("name").ifBlank { continue } - val serverIds = mutableListOf() - val sArr = p.optJSONArray("enabledMcpServerIds") ?: JSONArray() - for (j in 0 until sArr.length()) serverIds.add(sArr.optString(j)) - profiles.add( - AgentPromptProfile( - id = p.optString("id").ifBlank { UUID.randomUUID().toString() }, - name = name, - systemPrompt = p.optString("systemPrompt", ""), - enableTools = p.optBoolean("enableTools", true), - enableMcpTools = p.optBoolean("enableMcpTools", true), - allowCommandExecution = p.optBoolean("allowCommandExecution", false), - enabledMcpServerIds = serverIds, - skillPreset = p.optString("skillPreset").ifBlank { null }, - ) - ) + fun strList(key: String): List { val a = p.optJSONArray(key) ?: return emptyList(); return (0 until a.length()).map { a.optString(it) }.filter { it.isNotBlank() } } + profiles.add(AgentPromptProfile( + id = p.optString("id").ifBlank { UUID.randomUUID().toString() }, + name = name, + systemPrompt = p.optString("systemPrompt", ""), + enableTools = p.optBoolean("enableTools", true), + enableMcpTools = p.optBoolean("enableMcpTools", true), + allowCommandExecution = p.optBoolean("allowCommandExecution", false), + enabledMcpServerIds = strList("enabledMcpServerIds"), + disabledTools = strList("disabledTools"), + skillPreset = p.optString("skillPreset").ifBlank { null }, + )) } activeId to profiles } catch (_: Exception) { null to emptyList() } @@ -68,44 +67,27 @@ object AgentPromptProfileStore { val obj = JSONObject() obj.put("activeId", activeId ?: "") val arr = JSONArray() - profiles.forEach { p -> - arr.put(JSONObject().apply { - put("id", p.id); put("name", p.name); put("systemPrompt", p.systemPrompt) - put("enableTools", p.enableTools); put("enableMcpTools", p.enableMcpTools) - put("allowCommandExecution", p.allowCommandExecution) - put("enabledMcpServerIds", JSONArray(p.enabledMcpServerIds)) - put("skillPreset", p.skillPreset ?: "") - }) - } + profiles.forEach { p -> arr.put(JSONObject().apply { + put("id", p.id); put("name", p.name); put("systemPrompt", p.systemPrompt) + put("enableTools", p.enableTools); put("enableMcpTools", p.enableMcpTools) + put("allowCommandExecution", p.allowCommandExecution) + put("enabledMcpServerIds", JSONArray(p.enabledMcpServerIds)) + put("disabledTools", JSONArray(p.disabledTools)) + put("skillPreset", p.skillPreset ?: "") + }) } obj.put("profiles", arr) prefs().putString(KEY, obj.toString()) } - fun getActive(): AgentPromptProfile? { - val (activeId, profiles) = load() - return profiles.firstOrNull { it.id == activeId } ?: profiles.firstOrNull() - } - + fun getActive(): AgentPromptProfile? { val (activeId, profiles) = load(); return profiles.firstOrNull { it.id == activeId } ?: profiles.firstOrNull() } fun upsert(profile: AgentPromptProfile, makeActive: Boolean = false): AgentPromptProfile { - val (activeId, profiles) = load() - val mutable = profiles.toMutableList() - val idx = mutable.indexOfFirst { it.id == profile.id } + val (activeId, profiles) = load(); val mutable = profiles.toMutableList(); val idx = mutable.indexOfFirst { it.id == profile.id } if (idx >= 0) mutable[idx] = profile else mutable.add(profile) - save(if (makeActive || activeId == null) profile.id else activeId, mutable) - return profile - } - - fun delete(id: String) { - val (activeId, profiles) = load() - if (profiles.size <= 1) return - val remaining = profiles.filterNot { it.id == id } - save(if (activeId == id) remaining.firstOrNull()?.id else activeId, remaining) - } - - fun setActive(id: String) { - val (_, profiles) = load() - if (profiles.any { it.id == id }) save(id, profiles) + save(if (makeActive || activeId == null) profile.id else activeId, mutable); return profile } + fun delete(id: String) { val (activeId, profiles) = load(); if (profiles.size <= 1) return; val remaining = profiles.filterNot { it.id == id }; save(if (activeId == id) remaining.firstOrNull()?.id else activeId, remaining) } + fun setActive(id: String) { val (_, profiles) = load(); if (profiles.any { it.id == id }) save(id, profiles) } + fun addPreset(preset: AgentPromptProfile): AgentPromptProfile = upsert(preset.copy(id = UUID.randomUUID().toString()), makeActive = false) @Synchronized private fun ensureDefault() { @@ -113,9 +95,4 @@ object AgentPromptProfileStore { val def = AgentPromptProfile(name = "Default", systemPrompt = "", enableTools = true, enableMcpTools = true) save(def.id, listOf(def)) } - - fun addPreset(preset: AgentPromptProfile): AgentPromptProfile { - val copy = preset.copy(id = UUID.randomUUID().toString()) - return upsert(copy, makeActive = false) - } } diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/tools/AITools.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/tools/AITools.kt index da562b7300..4d602c9323 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/tools/AITools.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/tools/AITools.kt @@ -13,7 +13,11 @@ data class AIToolDef( val parametersJsonSchema: String, ) -data class AIToolCall(val name: String, val arguments: JSONObject, val raw: String) +data class AIToolCall(val name: String, val arguments: JSONObject, val raw: String = "", val id: String? = null) +data class AIToolResult(val name: String, val result: String, val toolCallId: String? = null) +data class ToolAwareResponse(val text: String = "", val toolCalls: List = emptyList()) { + fun hasToolCalls() = toolCalls.isNotEmpty() +} interface AIToolExecutor { fun definitions(): List @@ -25,6 +29,7 @@ class BuiltInToolExecutor( private var projectTree: ProjectTreeResult? = null, private var projectRoot: File? = null, private val allowCommandExecution: Boolean = false, + private val disabledTools: Set = emptySet(), ) : AIToolExecutor { private val writer = AIFileWriter(context) @@ -44,10 +49,12 @@ class BuiltInToolExecutor( if (allowCommandExecution) { defs.add(AIToolDef("run_command", "Run a shell command in the project directory (opt-in, use with caution). Returns stdout/stderr and exit code.", """{"type":"object","properties":{"command":{"type":"string"},"timeoutMs":{"type":"integer"}},"required":["command"]}""")) } - return defs + return defs.filterNot { it.name in disabledTools } } override fun execute(name: String, arguments: JSONObject): String { + if (name in disabledTools) return "Error: tool '$name' is disabled by the active agent profile." + if (name == "run_command" && !allowCommandExecution) return "Error: command execution is disabled by the active agent profile." return try { when (name) { "read_file" -> { @@ -94,7 +101,6 @@ class BuiltInToolExecutor( } } "run_command" -> { - if (!allowCommandExecution) return "Error: command execution is disabled by the active agent profile." val command = arguments.optString("command") if (command.isBlank()) return "Error: command is required" val timeoutMs = arguments.optLong("timeoutMs", 15000L).coerceIn(1000L, 60000L) @@ -108,9 +114,7 @@ class BuiltInToolExecutor( } else -> "Error: unknown tool '$name'" } - } catch (e: Exception) { - "Error: ${e.message}" - } + } catch (e: Exception) { "Error: ${e.message}" } } } @@ -129,8 +133,8 @@ object ToolCallProtocol { val obj = JSONObject(jsonText) val name = obj.optString("name").ifBlank { obj.optString("tool") } if (name.isBlank()) continue - val args = obj.optJSONObject("arguments") ?: obj.optJSONObject("args") ?: JSONObject() - return AIToolCall(name, args, m.value) + val args = obj.optJSONObject("arguments") ?: obj.optJSONObject("args") ?: try { JSONObject(obj.optString("arguments", "{}")) } catch (_: Exception) { JSONObject() } + return AIToolCall(name, args ?: JSONObject(), m.value, obj.optString("id").ifBlank { null }) } catch (_: Exception) {} } return null @@ -147,4 +151,19 @@ object ToolCallProtocol { sb.append("\n") return sb.toString() } + + fun openAIToolsJson(tools: List): org.json.JSONArray { + val arr = org.json.JSONArray() + tools.forEach { t -> + arr.put(org.json.JSONObject().apply { + put("type", "function") + put("function", org.json.JSONObject().apply { + put("name", t.name) + put("description", t.description) + put("parameters", try { org.json.JSONObject(t.parametersJsonSchema) } catch (_: Exception) { org.json.JSONObject().put("type","object").put("properties", org.json.JSONObject()) }) + }) + }) + } + return arr + } } From c1e2f3c1425ccd010f6a96919204c0aee832c647 Mon Sep 17 00:00:00 2001 From: Nabin_sharma <105613345+NabinSharmaitgithub@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:00:17 +0545 Subject: [PATCH 4/6] feat(ai): native tool/function-calling for OpenAI-compatible providers - Add OpenAICompatNative helper (OpenAI-style tools/tool_calls/tool results) - LocalLLM: full native tool-calling support (OpenAI-compatible endpoint) - OpenAI: native tool-calling overrides (generateWithTools/submitToolResults) - DeepSeek: native tool-calling overrides (OpenAI-compatible endpoint) - Grok: native tool-calling overrides (OpenAI-compatible endpoint) - Agent manager already prefers native tool path when supportsNativeTools() is true --- .../artificial/agents/deepseek/DeepSeek.kt | 55 ++ .../tom/rv2ide/artificial/agents/grok/Grok.kt | 57 ++- .../artificial/agents/local/LocalLLM.kt | 469 ++++-------------- .../rv2ide/artificial/agents/openai/OpenAI.kt | 53 ++ .../artificial/tools/OpenAICompatNative.kt | 48 ++ 5 files changed, 305 insertions(+), 377 deletions(-) create mode 100644 core/app/src/main/java/com/tom/rv2ide/artificial/tools/OpenAICompatNative.kt diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt index e660c9f1bb..38d61e3bab 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt @@ -21,6 +21,18 @@ import android.content.Context import com.tom.rv2ide.artificial.agents.AIAgent import com.tom.rv2ide.artificial.agents.AIAgentRegistry import com.tom.rv2ide.artificial.agents.ModificationAttempt +import com.tom.rv2ide.artificial.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.OpenAICompatNative +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor +import org.json.JSONArray +import org.json.JSONObject import com.tom.rv2ide.artificial.agents.Agents import com.tom.rv2ide.artificial.rules.WritingRules import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult @@ -47,6 +59,9 @@ class DeepSeek : AIAgent { private val maxRetryAttempts = 3 private var agents: Agents? = null private var selectedModel: String = "deepseek-chat" + private val nativeMessages = JSONArray() + private var nativeToolsActive = false + private var appContext: Context? = null override val providerId = "deepseek" override val providerName = "DeepSeek" @@ -93,6 +108,7 @@ class DeepSeek : AIAgent { } override fun setContext(context: Context) { + appContext = context.applicationContext fileWriter = AIFileWriter(context) } @@ -101,6 +117,7 @@ class DeepSeek : AIAgent { } override fun clearConversation() { + nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) conversationHistory.clear() modificationHistory.clear() currentAttemptCount = 0 @@ -465,6 +482,44 @@ class DeepSeek : AIAgent { return modifications } + + private fun composeSystemPrompt(ctx: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(ctx, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + private fun clearNative() { nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) } + override fun supportsNativeTools(): Boolean = true + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + val key = apiKey ?: return@withContext Result.failure(IllegalStateException("Not initialized")) + clearNative(); nativeToolsActive = true + nativeMessages.put(JSONObject().put("role","system").put("content", composeSystemPrompt(ctx,false))) + nativeMessages.put(JSONObject().put("role","user").put("content", prompt)) + val res = OpenAICompatNative.call(OpenAICompatNative.CallConfig("https://api.deepseek.com/chat/completions", key, selectedModel), nativeMessages, tools) + nativeMessages.put(if (res.hasToolCalls()) OpenAICompatNative.assistantToolCallsMessage(res) else JSONObject().put("role","assistant").put("content",res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { + val key = apiKey ?: return@withContext Result.failure(IllegalStateException("Not initialized")) + if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n"){it.result}, null, "kotlin", null, tools) + toolResults.forEach { nativeMessages.put(OpenAICompatNative.toolResultMessage(it)) } + val res = OpenAICompatNative.call(OpenAICompatNative.CallConfig("https://api.deepseek.com/chat/completions", key, selectedModel), nativeMessages, tools) + nativeMessages.put(if (res.hasToolCalls()) OpenAICompatNative.assistantToolCallsMessage(res) else JSONObject().put("role","assistant").put("content",res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override fun isInitialized(): Boolean = apiKey != null } diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt index f7b92d4bab..9c89741bc0 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt @@ -21,6 +21,18 @@ import android.content.Context import com.tom.rv2ide.artificial.agents.AIAgent import com.tom.rv2ide.artificial.agents.AIAgentRegistry import com.tom.rv2ide.artificial.agents.ModificationAttempt +import com.tom.rv2ide.artificial.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.OpenAICompatNative +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor +import org.json.JSONArray +import org.json.JSONObject import com.tom.rv2ide.artificial.agents.Agents import com.tom.rv2ide.artificial.rules.WritingRules import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult @@ -47,6 +59,9 @@ class Grok : AIAgent { private val maxRetryAttempts = 3 private var agents: Agents? = null private var selectedModel: String = "grok-beta" + private val nativeMessages = JSONArray() + private var nativeToolsActive = false + private var appContext: Context? = null override val providerId = "grok" override val providerName = "xAI Grok" @@ -93,6 +108,7 @@ class Grok : AIAgent { } override fun setContext(context: Context) { + appContext = context.applicationContext fileWriter = AIFileWriter(context) } @@ -101,6 +117,7 @@ class Grok : AIAgent { } override fun clearConversation() { + nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) conversationHistory.clear() modificationHistory.clear() currentAttemptCount = 0 @@ -465,6 +482,44 @@ class Grok : AIAgent { return modifications } + + private fun composeSystemPrompt(ctx: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(ctx, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + private fun clearNative() { nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) } + override fun supportsNativeTools(): Boolean = true + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + val key = apiKey ?: return@withContext Result.failure(IllegalStateException("Not initialized")) + clearNative(); nativeToolsActive = true + nativeMessages.put(JSONObject().put("role","system").put("content", composeSystemPrompt(ctx,false))) + nativeMessages.put(JSONObject().put("role","user").put("content", prompt)) + val res = OpenAICompatNative.call(OpenAICompatNative.CallConfig("https://api.x.ai/v1/chat/completions", key, selectedModel), nativeMessages, tools) + nativeMessages.put(if (res.hasToolCalls()) OpenAICompatNative.assistantToolCallsMessage(res) else JSONObject().put("role","assistant").put("content",res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { + val key = apiKey ?: return@withContext Result.failure(IllegalStateException("Not initialized")) + if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n"){it.result}, null, "kotlin", null, tools) + toolResults.forEach { nativeMessages.put(OpenAICompatNative.toolResultMessage(it)) } + val res = OpenAICompatNative.call(OpenAICompatNative.CallConfig("https://api.x.ai/v1/chat/completions", key, selectedModel), nativeMessages, tools) + nativeMessages.put(if (res.hasToolCalls()) OpenAICompatNative.assistantToolCallsMessage(res) else JSONObject().put("role","assistant").put("content",res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override fun isInitialized(): Boolean = apiKey != null } @@ -477,4 +532,4 @@ data class FileModification( data class ConversationMessage( val role: String, val content: String -) \ No newline at end of file +) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/local/LocalLLM.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/local/LocalLLM.kt index 602f6ed706..bf190dc300 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/local/LocalLLM.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/local/LocalLLM.kt @@ -18,37 +18,39 @@ package com.tom.rv2ide.artificial.agents.local import android.content.Context +import com.tom.rv2ide.app.BaseApplication import com.tom.rv2ide.artificial.agents.AIAgent import com.tom.rv2ide.artificial.agents.AIAgentRegistry -import com.tom.rv2ide.artificial.agents.ModificationAttempt import com.tom.rv2ide.artificial.agents.Agents -import com.tom.rv2ide.artificial.secrets.ApiKey -import com.tom.rv2ide.artificial.rules.WritingRules -import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult +import com.tom.rv2ide.artificial.agents.ModificationAttempt import com.tom.rv2ide.artificial.file.AIFileWriter import com.tom.rv2ide.artificial.file.FileWriteResult -import com.tom.rv2ide.managers.PreferenceManager -import com.tom.rv2ide.app.BaseApplication +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult +import com.tom.rv2ide.artificial.rules.WritingRules +import com.tom.rv2ide.artificial.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import java.io.File +import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.io.File +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.MediaType.Companion.toMediaType -import org.json.JSONObject import org.json.JSONArray -import java.util.concurrent.TimeUnit +import org.json.JSONObject class LocalLLM : AIAgent { - private var baseUrl: String? = null private var modelName: String? = null - private val httpClient = OkHttpClient.Builder() - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(120, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .build() + private val httpClient = OkHttpClient.Builder().connectTimeout(60, TimeUnit.SECONDS).readTimeout(120, TimeUnit.SECONDS).writeTimeout(60, TimeUnit.SECONDS).build() private val writingRules = WritingRules.Instructions() private var projectTreeResult: ProjectTreeResult? = null private var fileWriter: AIFileWriter? = null @@ -56,382 +58,97 @@ class LocalLLM : AIAgent { private val modificationHistory = mutableListOf() private var currentAttemptCount = 0 private val maxRetryAttempts = 3 - private var agents: Agents? = null + private var appContext: Context? = null + private val nativeMessages = JSONArray() + private var nativeToolsActive = false override val providerId = "localllm" override val providerName = "Local LLM" companion object { - fun registerAgent() { - AIAgentRegistry.register("localllm", object : AIAgentRegistry.AgentFactory { - override fun create(context: Context): AIAgent { - return LocalLLM() - } - - override fun hasValidApiKey(): Boolean { - val prefs = BaseApplication.getBaseInstance().prefManager - val url = prefs.getString("local_llm_base_url", null) - val model = prefs.getString("local_llm_model_name", null) - android.util.Log.d("LocalLLM", "hasValidApiKey check: url=$url, model=$model") - return url != null && url.isNotEmpty() && model != null && model.isNotEmpty() - } - - override fun getApiKey(): String? { - return "local" - } - }) - } - } - - override fun initialize(apiKey: String, context: Context) { - try { - agents = Agents(context) - val prefs = BaseApplication.getBaseInstance().prefManager - baseUrl = prefs.getString("local_llm_base_url", null) - modelName = prefs.getString("local_llm_model_name", null) - - android.util.Log.d("LocalLLM", "Initialized with baseUrl=$baseUrl, model=$modelName") - - if (baseUrl == null || baseUrl!!.isEmpty() || modelName == null || modelName!!.isEmpty()) { - throw IllegalStateException("Local LLM not configured. Please set base URL and model name.") - } - } catch (e: Exception) { - throw e - } - } - - override fun isInitialized(): Boolean = baseUrl != null && baseUrl!!.isNotEmpty() && modelName != null && modelName!!.isNotEmpty() - - - override fun reinitializeWithNewModel(apiKey: String, context: Context) { - initialize(apiKey, context) - } - - override fun setContext(context: Context) { - fileWriter = AIFileWriter(context) - } - - override fun setProjectData(projectTreeResult: ProjectTreeResult) { - this.projectTreeResult = projectTreeResult - } - - override fun clearConversation() { - conversationHistory.clear() - modificationHistory.clear() - currentAttemptCount = 0 - } - - override fun recordModification(filePath: String, oldContent: String?, newContent: String, success: Boolean) { - modificationHistory.add( - ModificationAttempt( - timestamp = System.currentTimeMillis(), - filePath = filePath, - previousContent = oldContent, - newContent = newContent, - attemptNumber = currentAttemptCount, - success = success - ) - ) - } - - override fun undoLastModification(): Boolean { - if (modificationHistory.isEmpty()) return false - - val lastMod = modificationHistory.lastOrNull { it.success } ?: return false - - if (lastMod.previousContent != null) { - val result = writeFile(lastMod.filePath, lastMod.previousContent) - if (result is FileWriteResult.Success) { - modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)) - return true - } - } else { - try { - File(lastMod.filePath).delete() - modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)) - return true - } catch (e: Exception) { - return false - } + fun registerAgent() { + AIAgentRegistry.register("localllm", object : AIAgentRegistry.AgentFactory { + override fun create(context: Context): AIAgent = LocalLLM() + override fun hasValidApiKey(): Boolean { val prefs = BaseApplication.getBaseInstance().prefManager; val url = prefs.getString("local_llm_base_url", null); val model = prefs.getString("local_llm_model_name", null); return !url.isNullOrEmpty() && !model.isNullOrEmpty() } + override fun getApiKey(): String? = "local" + }) } - return false - } - - override fun getModificationHistory(): List { - return modificationHistory.toList() - } - - override fun resetAttemptCount() { - currentAttemptCount = 0 - } - - override fun incrementAttemptCount() { - currentAttemptCount++ } + override fun initialize(apiKey: String, context: Context) { + appContext = context.applicationContext + val prefs = BaseApplication.getBaseInstance().prefManager + baseUrl = prefs.getString("local_llm_base_url", null); modelName = prefs.getString("local_llm_model_name", null) + if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) throw IllegalStateException("Local LLM not configured. Please set base URL and model name.") + } + override fun isInitialized(): Boolean = !baseUrl.isNullOrEmpty() && !modelName.isNullOrEmpty() + override fun reinitializeWithNewModel(apiKey: String, context: Context) { initialize(apiKey, context) } + override fun setContext(context: Context) { appContext = context.applicationContext; fileWriter = AIFileWriter(context) } + override fun setProjectData(projectTreeResult: ProjectTreeResult) { this.projectTreeResult = projectTreeResult } + override fun clearConversation() { conversationHistory.clear(); modificationHistory.clear(); currentAttemptCount = 0; clearNative() } + override fun recordModification(filePath: String, oldContent: String?, newContent: String, success: Boolean) { modificationHistory.add(ModificationAttempt(System.currentTimeMillis(), filePath, oldContent, newContent, currentAttemptCount, success)) } + override fun undoLastModification(): Boolean { if (modificationHistory.isEmpty()) return false; val lastMod = modificationHistory.lastOrNull { it.success } ?: return false; if (lastMod.previousContent != null) { val result = writeFile(lastMod.filePath, lastMod.previousContent); if (result is FileWriteResult.Success) { modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)); return true } } else { try { File(lastMod.filePath).delete(); modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)); return true } catch (_: Exception) {} }; return false } + override fun getModificationHistory(): List = modificationHistory.toList() + override fun resetAttemptCount() { currentAttemptCount = 0 } + override fun incrementAttemptCount() { currentAttemptCount++ } override fun getCurrentAttemptCount(): Int = currentAttemptCount - override fun canRetry(): Boolean = currentAttemptCount < maxRetryAttempts - private fun isUserRequestingCorrection(message: String): Boolean { - val correctionKeywords = listOf( - "wrong", "not what", "mistake", "error", "incorrect", - "that's not", "not right", "fix", "undo", "revert", - "different", "try again", "not working" - ) - return correctionKeywords.any { message.lowercase().contains(it) } - } - - override suspend fun generateCode( - prompt: String, - context: String?, - language: String, - projectStructure: String?, - ): Result = - withContext(Dispatchers.IO) { - try { - if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) { - return@withContext Result.failure( - IllegalStateException("Local LLM not configured") - ) - } - - val fileContents = readRelevantFiles() - val needsCorrection = isUserRequestingCorrection(prompt) - - val fullPrompt = buildString { - append("=== PROJECT STRUCTURE (THESE ARE THE EXACT PATHS YOU MUST USE) ===\n") - if (projectTreeResult != null) { - append(projectTreeResult!!.tree) - append("\n\n") - append("CRITICAL: Use ONLY the paths shown above. Do NOT make up fake paths like '/storage/emulated/0/project' or 'com.example.yourproject'.\n") - append("CRITICAL: Look at the actual paths above and use those EXACT paths.\n\n") - } - - if (fileContents.isNotEmpty()) { - append("=== CURRENT FILES CONTENT ===\n") - fileContents.forEach { (path, content) -> - append("FILE: $path\n") - append("CONTENT:\n") - append(content) - append("\n\n") - } - } - - if (context != null) { - append("=== ADDITIONAL CONTEXT ===\n") - append(context) - append("\n\n") - } - - if (conversationHistory.isNotEmpty()) { - append("=== CONVERSATION HISTORY ===\n") - conversationHistory.forEach { msg -> - append("${msg.role.uppercase()}: ${msg.content}\n\n") - } - } - - if (needsCorrection && modificationHistory.isNotEmpty()) { - append("=== CORRECTION REQUIRED ===\n") - append("The user indicated the previous modification was WRONG.\n") - append("Previous failed attempts:\n") - modificationHistory.takeLast(3).forEach { attempt -> - append("Attempt ${attempt.attemptNumber}: ${attempt.filePath}\n") - append("Result: ${if (attempt.success) "Applied but user rejected" else "Failed"}\n\n") - } - append("You MUST try a DIFFERENT approach. Do NOT repeat the same solution.\n") - append("Analyze what went wrong and provide a better solution.\n\n") - } - - if (currentAttemptCount > 0) { - append("=== RETRY ATTEMPT $currentAttemptCount/$maxRetryAttempts ===\n") - append("This is retry attempt number $currentAttemptCount.\n") - append("Previous attempts did not satisfy the user.\n") - append("Think carefully and provide a different solution.\n\n") - } - - append("=== USER REQUEST ===\n") - append(prompt) - } - - val messages = JSONArray() - messages.put(JSONObject().apply { - put("role", "system") - put("content", writingRules.useThis()) - }) - messages.put(JSONObject().apply { - put("role", "user") - put("content", fullPrompt) - }) - - val requestBody = JSONObject().apply { - put("model", modelName) - put("messages", messages) - put("temperature", 0.7) - put("stream", false) - } - - val request = Request.Builder() - .url("$baseUrl/v1/chat/completions") - .post(requestBody.toString().toRequestBody("application/json".toMediaType())) - .build() - - val response = try { - httpClient.newCall(request).execute() - } catch (e: Exception) { - val errorMessage = e.message ?: "" - when { - errorMessage.contains("timeout") || - errorMessage.contains("connect") -> - throw com.tom.rv2ide.artificial.exceptions.RateLimitException( - "Connection timeout. Please check your local server." - ) - else -> throw e - } - } - - if (!response.isSuccessful) { - val errorBody = response.body?.string() ?: "Unknown error" - return@withContext Result.failure( - Exception("Local LLM error ${response.code}: $errorBody") - ) - } - - val responseBody = response.body?.string() ?: "" - val jsonResponse = JSONObject(responseBody) - - val generatedResponse = jsonResponse - .getJSONArray("choices") - .getJSONObject(0) - .getJSONObject("message") - .getString("content") - - if (generatedResponse.isBlank()) { - return@withContext Result.failure(Exception("Empty response from Local LLM")) - } - - conversationHistory.add(ConversationMessage("user", prompt)) - conversationHistory.add(ConversationMessage("assistant", generatedResponse)) - - if (conversationHistory.size > 20) { - conversationHistory.removeAt(0) - conversationHistory.removeAt(0) - } - - Result.success(generatedResponse) - } catch (e: com.tom.rv2ide.artificial.exceptions.RateLimitException) { - Result.failure(e) - } catch (e: com.tom.rv2ide.artificial.exceptions.QuotaExceededException) { - Result.failure(e) - } catch (e: com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException) { - Result.failure(e) - } catch (e: Exception) { - Result.failure(e) - } - } - - private fun readRelevantFiles(): Map { - val filesContent = mutableMapOf() - val tree = projectTreeResult?.tree ?: return filesContent - - val filePaths = tree.lines().filter { it.isNotBlank() } - - filePaths.forEach { filePath -> - val trimmedPath = filePath.trim() - val file = File(trimmedPath) - - if (file.isFile && - (trimmedPath.endsWith(".kt") || - trimmedPath.endsWith(".java") || - trimmedPath.endsWith(".xml") || - trimmedPath.endsWith(".gradle") || - trimmedPath.endsWith(".gradle.kts")) && - !trimmedPath.contains("/build/") && - !trimmedPath.contains("/.gradle/")) { - try { - val content = file.readText() - filesContent[trimmedPath] = content - } catch (e: Exception) { - } - } + private fun isUserRequestingCorrection(message: String): Boolean = listOf("wrong","not what","mistake","error","incorrect","that's not","not right","fix","undo","revert","different","try again","not working").any { message.lowercase().contains(it) } + private fun composeSystemPrompt(context: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(context, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + private fun buildUserPrompt(prompt: String, context: String?): String { + val fileContents = readRelevantFiles(); val needsCorrection = isUserRequestingCorrection(prompt) + return buildString { + append("=== PROJECT STRUCTURE (THESE ARE THE EXACT PATHS YOU MUST USE) ===\n") + if (projectTreeResult != null) { append(projectTreeResult!!.tree); append("\n\nCRITICAL: Use ONLY the paths shown above.\n\n") } + if (fileContents.isNotEmpty()) { append("=== CURRENT FILES CONTENT ===\n"); fileContents.forEach { (p,c)-> append("FILE: $p\nCONTENT:\n").append(c).append("\n\n") } } + if (context != null) append("=== ADDITIONAL CONTEXT ===\n").append(context).append("\n\n") + if (conversationHistory.isNotEmpty()) { append("=== CONVERSATION HISTORY ===\n"); conversationHistory.takeLast(8).forEach { msg -> append("${msg.role.uppercase()}: ${msg.content.take(3000)}\n\n") } } + if (needsCorrection && modificationHistory.isNotEmpty()) append("=== CORRECTION REQUIRED ===\nTry a COMPLETELY DIFFERENT approach.\n\n") + if (currentAttemptCount > 0) append("=== RETRY ATTEMPT $currentAttemptCount/$maxRetryAttempts ===\n\n") + append("=== USER REQUEST ===\n").append(prompt) } - - return filesContent } - fun readFile(filePath: String): String? { - return try { - if (File(filePath).exists()) { - File(filePath).readText() - } else { - projectTreeResult?.readFileContent(File(filePath).name) - } - } catch (e: Exception) { - null - } + override suspend fun generateCode(prompt: String, context: String?, language: String, projectStructure: String?): Result = withContext(Dispatchers.IO) { + try { val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")); clearNative(); val res = callChat(buildMessages(composeSystemPrompt(ctx,true), buildUserPrompt(prompt,context)), null); conversationHistory.add(ConversationMessage("user", prompt)); conversationHistory.add(ConversationMessage("assistant", res.text)); while (conversationHistory.size>20) conversationHistory.removeAt(0); Result.success(res.text) } catch (e: Exception) { Result.failure(e) } } - override fun writeFile(filePath: String, content: String): FileWriteResult { - val writer = fileWriter ?: return FileWriteResult.Error("File writer not initialized") - return writer.writeFile(filePath, content, createBackup = true) + override fun supportsNativeTools(): Boolean = true + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")); clearNative(); nativeToolsActive=true; nativeMessages.put(JSONObject().put("role","system").put("content", composeSystemPrompt(ctx,false))); nativeMessages.put(JSONObject().put("role","user").put("content", buildUserPrompt(prompt,context))); val res = callChat(nativeMessages, tools); nativeMessages.put(if (res.hasToolCalls()) assistantToolCallMessage(res) else JSONObject().put("role","assistant").put("content",res.text)); Result.success(res) } catch (e: Exception) { Result.failure(e) } } - - fun parseAndApplyModifications(response: String, capturedStates: Map): List { - val modifications = mutableListOf() - val parser = com.tom.rv2ide.artificial.parser.SnippetParser() - - if (response.contains("FILE_TO_MODIFY:")) { - val lines = response.lines() - var currentFile: String? = null - val contentBuilder = StringBuilder() - var inContent = false - - for (line in lines) { - if (line.startsWith("FILE_TO_MODIFY:")) { - if (currentFile != null && contentBuilder.isNotEmpty()) { - val rawContent = contentBuilder.toString().trim() - val cleanedContent = parser.cleanFileContent(rawContent) - val previousContent = capturedStates[currentFile] - val writeResult = writeFile(currentFile, cleanedContent) - - val success = writeResult is FileWriteResult.Success - recordModification(currentFile, previousContent, cleanedContent, success) - - modifications.add(FileModification(currentFile, cleanedContent, writeResult)) - } - - currentFile = line.substringAfter("FILE_TO_MODIFY:").trim() - contentBuilder.clear() - inContent = true - } else if (inContent) { - contentBuilder.append(line).append("\n") - } - } - - if (currentFile != null && contentBuilder.isNotEmpty()) { - val rawContent = contentBuilder.toString().trim() - val cleanedContent = parser.cleanFileContent(rawContent) - val previousContent = capturedStates[currentFile] - val writeResult = writeFile(currentFile, cleanedContent) - - val success = writeResult is FileWriteResult.Success - recordModification(currentFile, previousContent, cleanedContent, success) - - modifications.add(FileModification(currentFile, cleanedContent, writeResult)) - } - } - - return modifications + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n"){it.result}, null, "kotlin", null, tools); toolResults.forEach { tr -> nativeMessages.put(JSONObject().put("role","tool").put("tool_call_id", tr.toolCallId ?: tr.name).put("content", tr.result)) }; val res = callChat(nativeMessages, tools); nativeMessages.put(if (res.hasToolCalls()) assistantToolCallMessage(res) else JSONObject().put("role","assistant").put("content",res.text)); Result.success(res) } catch (e: Exception) { Result.failure(e) } } + private fun assistantToolCallMessage(res: ToolAwareResponse): JSONObject = JSONObject().apply { put("role","assistant"); put("content",res.text); val arr = JSONArray(); res.toolCalls.forEach { tc -> arr.put(JSONObject().apply { put("id", tc.id ?: "call_${tc.name}_${System.nanoTime()}"); put("type","function"); put("function", JSONObject().put("name",tc.name).put("arguments",tc.arguments.toString())) }) }; put("tool_calls",arr) } + private fun clearNative() { nativeToolsActive=false; while(nativeMessages.length()>0) nativeMessages.remove(0) } + private fun buildMessages(system: String, user: String): JSONArray = JSONArray().apply { put(JSONObject().put("role","system").put("content",system)); put(JSONObject().put("role","user").put("content",user)) } + private fun callChat(messages: JSONArray, tools: List?): ToolAwareResponse { + if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) throw IllegalStateException("Local LLM not configured") + val requestBody = JSONObject().apply { put("model", modelName); put("messages", messages); put("temperature", 0.7); put("stream", false); if (!tools.isNullOrEmpty()) { put("tools", ToolCallProtocol.openAIToolsJson(tools)); put("tool_choice","auto") } } + val req = Request.Builder().url("${baseUrl!!.trimEnd('/')}/v1/chat/completions").post(requestBody.toString().toRequestBody("application/json".toMediaType())).addHeader("Content-Type","application/json").build() + val response = httpClient.newCall(req).execute() + if (!response.isSuccessful) throw Exception("Local LLM error ${response.code}: ${response.body?.string() ?: "Unknown"}") + val body = response.body?.string().orEmpty(); val json = JSONObject(body); val msg = json.getJSONArray("choices").getJSONObject(0).getJSONObject("message") + val text = msg.optString("content",""); val calls = mutableListOf() + val arr = msg.optJSONArray("tool_calls") + if (arr != null) for (i in 0 until arr.length()) { val tc = arr.optJSONObject(i) ?: continue; val fn = tc.optJSONObject("function") ?: continue; val n = fn.optString("name"); if (n.isBlank()) continue; val a = try { JSONObject(fn.optString("arguments","{}").ifBlank{"{}"}) } catch (_: Exception){ JSONObject() }; calls.add(AIToolCall(n,a,tc.toString(), tc.optString("id").ifBlank{null})) } + if (calls.isEmpty()) { ToolCallProtocol.parse(text)?.let { calls.add(it) } } + return ToolAwareResponse(text, calls) + } + private fun readRelevantFiles(): Map { val filesContent = mutableMapOf(); val tree = projectTreeResult?.tree ?: return filesContent; tree.lines().filter { it.isNotBlank() }.forEach { filePath -> val t=filePath.trim(); val f=File(t); if (f.isFile && (t.endsWith(".kt")||t.endsWith(".java")||t.endsWith(".xml")||t.endsWith(".gradle")||t.endsWith(".gradle.kts")) && !t.contains("/build/") && !t.contains("/.gradle/")) try { filesContent[t]=f.readText() } catch (_: Exception) {} }; return filesContent } + override fun writeFile(filePath: String, content: String): FileWriteResult = fileWriter?.writeFile(filePath, content, createBackup=true) ?: FileWriteResult.Error("File writer not initialized") } - -data class ConversationMessage( - val role: String, - val content: String -) - -data class FileModification( - val filePath: String, - val content: String, - val writeResult: FileWriteResult -) \ No newline at end of file +data class ConversationMessage(val role: String, val content: String) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/openai/OpenAI.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/openai/OpenAI.kt index b110aa14d5..06f6201206 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/openai/OpenAI.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/openai/OpenAI.kt @@ -36,6 +36,16 @@ import com.tom.rv2ide.artificial.agents.AIAgent import com.tom.rv2ide.artificial.agents.AIAgentRegistry import com.tom.rv2ide.artificial.secrets.ApiKey import com.tom.rv2ide.artificial.agents.ModificationAttempt +import com.tom.rv2ide.artificial.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.OpenAICompatNative +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor /* * @author Mohammed-baqer-null @ https://github.com/Mohammed-baqer-null @@ -53,6 +63,9 @@ class OpenAI : AIAgent { private val maxRetryAttempts = 3 private var agents: Agents? = null private var selectedModel: String = "gpt-4o" + private val nativeMessages = JSONArray() + private var nativeToolsActive = false + private var appContext: Context? = null override val providerId = "openai" override val providerName = "OpenAI" @@ -102,6 +115,7 @@ class OpenAI : AIAgent { } override fun setContext(context: Context) { + appContext = context.applicationContext fileWriter = AIFileWriter(context) } @@ -110,6 +124,7 @@ class OpenAI : AIAgent { } override fun clearConversation() { + nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) conversationHistory.clear() modificationHistory.clear() currentAttemptCount = 0 @@ -477,6 +492,44 @@ class OpenAI : AIAgent { return modifications } + + private fun composeSystemPrompt(ctx: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(ctx, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + private fun clearNative() { nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) } + override fun supportsNativeTools(): Boolean = true + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + val key = apiKey ?: return@withContext Result.failure(IllegalStateException("OpenAI service not initialized")) + clearNative(); nativeToolsActive = true + nativeMessages.put(JSONObject().put("role","system").put("content", composeSystemPrompt(ctx,false))) + nativeMessages.put(JSONObject().put("role","user").put("content", prompt)) + val res = OpenAICompatNative.call(OpenAICompatNative.CallConfig("https://api.openai.com/v1/chat/completions", key, selectedModel), nativeMessages, tools) + nativeMessages.put(if (res.hasToolCalls()) OpenAICompatNative.assistantToolCallsMessage(res) else JSONObject().put("role","assistant").put("content",res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { + val key = apiKey ?: return@withContext Result.failure(IllegalStateException("OpenAI service not initialized")) + if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n"){it.result}, null, "kotlin", null, tools) + toolResults.forEach { nativeMessages.put(OpenAICompatNative.toolResultMessage(it)) } + val res = OpenAICompatNative.call(OpenAICompatNative.CallConfig("https://api.openai.com/v1/chat/completions", key, selectedModel), nativeMessages, tools) + nativeMessages.put(if (res.hasToolCalls()) OpenAICompatNative.assistantToolCallsMessage(res) else JSONObject().put("role","assistant").put("content",res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override fun isInitialized(): Boolean = apiKey != null } diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/tools/OpenAICompatNative.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/tools/OpenAICompatNative.kt new file mode 100644 index 0000000000..dd59e1e462 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/tools/OpenAICompatNative.kt @@ -0,0 +1,48 @@ +package com.tom.rv2ide.artificial.tools + +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +object OpenAICompatNative { + data class CallConfig(val endpoint: String, val apiKey: String?, val model: String, val extraHeaders: Map = emptyMap()) + + fun call(cfg: CallConfig, messages: JSONArray, tools: List?): ToolAwareResponse { + val body = JSONObject().apply { + put("model", cfg.model); put("messages", messages); put("temperature", 0.7) + if (!tools.isNullOrEmpty()) { put("tools", ToolCallProtocol.openAIToolsJson(tools)); put("tool_choice", "auto") } + } + val conn = URL(cfg.endpoint).openConnection() as HttpURLConnection + try { + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Accept", "application/json") + cfg.apiKey?.takeIf { it.isNotBlank() }?.let { conn.setRequestProperty("Authorization", "Bearer $it") } + cfg.extraHeaders.forEach { (k,v) -> if (k.isNotBlank()) conn.setRequestProperty(k,v) } + conn.doOutput = true; conn.connectTimeout = 30000; conn.readTimeout = 90000 + conn.outputStream.use { it.write(body.toString().toByteArray()) } + val code = conn.responseCode + val text = (if (code in 200..299) conn.inputStream else conn.errorStream)?.bufferedReader()?.readText().orEmpty() + if (code !in 200..299) throw Exception("API error $code: $text") + val msg = JSONObject(text).getJSONArray("choices").getJSONObject(0).getJSONObject("message") + val content = msg.optString("content", "") + val calls = mutableListOf() + val arr = msg.optJSONArray("tool_calls") + if (arr != null) for (i in 0 until arr.length()) { + val tc = arr.optJSONObject(i) ?: continue; val fn = tc.optJSONObject("function") ?: continue + val n = fn.optString("name"); if (n.isBlank()) continue + val a = try { JSONObject(fn.optString("arguments","{}").ifBlank{"{}"}) } catch (_: Exception){ JSONObject() } + calls.add(AIToolCall(n,a,tc.toString(), tc.optString("id").ifBlank{null})) + } + if (calls.isEmpty()) ToolCallProtocol.parse(content)?.let { calls.add(it) } + return ToolAwareResponse(content, calls) + } finally { conn.disconnect() } + } + + fun assistantToolCallsMessage(res: ToolAwareResponse): JSONObject = JSONObject().apply { + put("role","assistant"); put("content", res.text) + val arr = JSONArray(); res.toolCalls.forEach { tc -> arr.put(JSONObject().apply { put("id", tc.id ?: "call_${tc.name}_${System.nanoTime()}"); put("type","function"); put("function", JSONObject().put("name",tc.name).put("arguments",tc.arguments.toString())) }) }; put("tool_calls", arr) + } + fun toolResultMessage(tr: AIToolResult): JSONObject = JSONObject().put("role","tool").put("tool_call_id", tr.toolCallId ?: tr.name).put("content", tr.result) +} From 1dfba8a38142cae511bfe041747e955b2afadaf1 Mon Sep 17 00:00:00 2001 From: Nabin_sharma <105613345+NabinSharmaitgithub@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:06:23 +0545 Subject: [PATCH 5/6] feat(ai): native tool/function-calling for Anthropic and Gemini - Anthropic: native tool use/tool_result via Messages API (raw HTTP), structured content blocks, system+agent profile composition - Gemini: native function calling via Gemini REST generateContent (functionDeclarations/functionCall/functionResponse), avoids SDK version coupling - Both integrate with AIAgentManager native tool loop and support per-tool/profile toggles --- .../artificial/agents/anthropic/Anthropic.kt | 118 ++++++++++++++++ .../rv2ide/artificial/agents/google/Gemini.kt | 126 +++++++++++++++++- 2 files changed, 243 insertions(+), 1 deletion(-) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/anthropic/Anthropic.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/anthropic/Anthropic.kt index 03259fc4ee..a17b5b1e79 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/anthropic/Anthropic.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/anthropic/Anthropic.kt @@ -21,6 +21,15 @@ import android.content.Context import com.tom.rv2ide.artificial.agents.AIAgent import com.tom.rv2ide.artificial.agents.AIAgentRegistry import com.tom.rv2ide.artificial.agents.ModificationAttempt +import com.tom.rv2ide.artificial.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor import com.tom.rv2ide.artificial.agents.Agents import com.tom.rv2ide.artificial.rules.WritingRules import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult @@ -51,6 +60,9 @@ class Anthropic : AIAgent { private val maxRetryAttempts = 3 private var agents: Agents? = null private var selectedModel: String = "claude-sonnet-4-20250514" + private val nativeMessages = JSONArray() + private var nativeToolsActive = false + private var appContext: Context? = null override val providerId = "claude" override val providerName = "Anthropic Claude" @@ -97,6 +109,7 @@ class Anthropic : AIAgent { } override fun setContext(context: Context) { + appContext = context.applicationContext fileWriter = AIFileWriter(context) } @@ -105,6 +118,7 @@ class Anthropic : AIAgent { } override fun clearConversation() { + nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) conversationHistory.clear() modificationHistory.clear() currentAttemptCount = 0 @@ -460,6 +474,110 @@ class Anthropic : AIAgent { return modifications } + + private fun composeSystemPrompt(ctx: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(ctx, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + private fun clearNative() { nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) } + + private fun anthropicToolsJson(tools: List): JSONArray { + val arr = JSONArray() + tools.forEach { t -> + arr.put(JSONObject().apply { + put("name", t.name) + put("description", t.description) + put("input_schema", try { JSONObject(t.parametersJsonSchema) } catch (_: Exception) { JSONObject().put("type","object").put("properties", JSONObject()) }) + }) + } + return arr + } + + private fun callAnthropicNative(messages: JSONArray, system: String, tools: List?): ToolAwareResponse { + val key = apiKey ?: throw IllegalStateException("Anthropic service not initialized") + val url = URL("https://api.anthropic.com/v1/messages") + val connection = url.openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("x-api-key", key) + connection.setRequestProperty("anthropic-version", "2023-06-01") + connection.doOutput = true + connection.connectTimeout = 30000 + connection.readTimeout = 90000 + val requestBody = JSONObject().apply { + put("model", selectedModel) + put("max_tokens", 4096) + put("system", system) + put("messages", messages) + if (!tools.isNullOrEmpty()) put("tools", anthropicToolsJson(tools)) + } + connection.outputStream.use { it.write(requestBody.toString().toByteArray()) } + val responseCode = connection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + val errorStream = connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error" + throw Exception("Anthropic API error ($responseCode): $errorStream") + } + val jsonResponse = JSONObject(connection.inputStream.bufferedReader().readText()) + val content = jsonResponse.optJSONArray("content") ?: JSONArray() + val sb = StringBuilder() + val calls = mutableListOf() + for (i in 0 until content.length()) { + val block = content.optJSONObject(i) ?: continue + when (block.optString("type")) { + "text" -> sb.append(block.optString("text")) + "tool_use" -> { + val name = block.optString("name") + val input = block.optJSONObject("input") ?: JSONObject() + if (name.isNotBlank()) calls.add(AIToolCall(name, input, block.toString(), block.optString("id").ifBlank { null })) + } + } + } + val text = sb.toString() + if (calls.isEmpty()) ToolCallProtocol.parse(text)?.let { calls.add(it) } + return ToolAwareResponse(text, calls) + } finally { connection.disconnect() } + } + + override fun supportsNativeTools(): Boolean = true + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + clearNative(); nativeToolsActive = true + nativeMessages.put(JSONObject().put("role","user").put("content", prompt)) + val res = callAnthropicNative(nativeMessages, composeSystemPrompt(ctx, false), tools) + // store assistant message with content blocks + val assistantContent = JSONArray() + if (res.text.isNotBlank()) assistantContent.put(JSONObject().put("type","text").put("text", res.text)) + res.toolCalls.forEach { tc -> assistantContent.put(JSONObject().put("type","tool_use").put("id", tc.id ?: "toolu_${tc.name}_${System.nanoTime()}").put("name", tc.name).put("input", tc.arguments)) } + if (assistantContent.length() > 0) nativeMessages.put(JSONObject().put("role","assistant").put("content", assistantContent)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n"){it.result}, null, "kotlin", null, tools) + val resultBlocks = JSONArray() + toolResults.forEach { tr -> resultBlocks.put(JSONObject().put("type","tool_result").put("tool_use_id", tr.toolCallId ?: tr.name).put("content", tr.result)) } + nativeMessages.put(JSONObject().put("role","user").put("content", resultBlocks)) + val res = callAnthropicNative(nativeMessages, composeSystemPrompt(ctx, false), tools) + val assistantContent = JSONArray() + if (res.text.isNotBlank()) assistantContent.put(JSONObject().put("type","text").put("text", res.text)) + res.toolCalls.forEach { tc -> assistantContent.put(JSONObject().put("type","tool_use").put("id", tc.id ?: "toolu_${tc.name}_${System.nanoTime()}").put("name", tc.name).put("input", tc.arguments)) } + if (assistantContent.length() > 0) nativeMessages.put(JSONObject().put("role","assistant").put("content", assistantContent)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override fun isInitialized(): Boolean = apiKey != null } diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/google/Gemini.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/google/Gemini.kt index 440250a465..aec3ef3cd4 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/google/Gemini.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/google/Gemini.kt @@ -23,6 +23,19 @@ import com.google.ai.client.generativeai.type.content import com.tom.rv2ide.artificial.agents.AIAgent import com.tom.rv2ide.artificial.agents.AIAgentRegistry import com.tom.rv2ide.artificial.agents.ModificationAttempt +import com.tom.rv2ide.artificial.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL import com.tom.rv2ide.artificial.secrets.ApiKey import com.tom.rv2ide.artificial.services.ArtificialService import com.tom.rv2ide.artificial.rules.WritingRules @@ -41,6 +54,11 @@ import com.tom.rv2ide.artificial.agents.Agents class Gemini : AIAgent { private var generativeModel: GenerativeModel? = null + private var apiKey: String? = null + private var selectedModel: String = "gemini-2.5-pro" + private var appContext: Context? = null + private val nativeContents = JSONArray() + private var nativeToolsActive = false private val writingRules = WritingRules.Instructions() private var projectTreeResult: ProjectTreeResult? = null private var fileWriter: AIFileWriter? = null @@ -86,6 +104,8 @@ class Gemini : AIAgent { agents?.setProvider("gemini") } + this.apiKey = apiKey + this.selectedModel = selectedModel generativeModel = GenerativeModel( modelName = selectedModel, apiKey = apiKey, @@ -101,6 +121,7 @@ class Gemini : AIAgent { } override fun setContext(context: Context) { + appContext = context.applicationContext fileWriter = AIFileWriter(context) } @@ -109,6 +130,7 @@ class Gemini : AIAgent { } override fun clearConversation() { + nativeToolsActive = false; while (nativeContents.length() > 0) nativeContents.remove(0) conversationHistory.clear() modificationHistory.clear() currentAttemptCount = 0 @@ -393,6 +415,108 @@ class Gemini : AIAgent { return modifications } + + private fun composeSystemPrompt(ctx: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(ctx, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + private fun clearNative() { nativeToolsActive = false; while (nativeContents.length() > 0) nativeContents.remove(0) } + + private fun geminiToolsJson(tools: List): JSONArray { + val decls = JSONArray() + tools.forEach { t -> + decls.put(JSONObject().apply { + put("name", t.name) + put("description", t.description) + put("parameters", try { JSONObject(t.parametersJsonSchema) } catch (_: Exception) { JSONObject().put("type","object").put("properties", JSONObject()) }) + }) + } + return JSONArray().put(JSONObject().put("functionDeclarations", decls)) + } + + private fun callGeminiNative(contents: JSONArray, system: String, tools: List?): ToolAwareResponse { + val key = apiKey ?: throw IllegalStateException("Gemini not initialized") + val model = selectedModel.ifBlank { "gemini-2.5-pro" } + val endpoint = "https://generativelanguage.googleapis.com/v1beta/models/$model:generateContent?key=$key" + val connection = URL(endpoint).openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.doOutput = true + connection.connectTimeout = 30000 + connection.readTimeout = 90000 + val body = JSONObject().apply { + put("contents", contents) + put("systemInstruction", JSONObject().put("parts", JSONArray().put(JSONObject().put("text", system)))) + if (!tools.isNullOrEmpty()) put("tools", geminiToolsJson(tools)) + put("generationConfig", JSONObject().put("temperature", 0.7)) + } + connection.outputStream.use { it.write(body.toString().toByteArray()) } + val code = connection.responseCode + val text = (if (code in 200..299) connection.inputStream else connection.errorStream)?.bufferedReader()?.readText().orEmpty() + if (code !in 200..299) throw Exception("Gemini API error ($code): $text") + val json = JSONObject(text) + val parts = json.optJSONArray("candidates")?.optJSONObject(0) + ?.optJSONObject("content")?.optJSONArray("parts") ?: JSONArray() + val sb = StringBuilder() + val calls = mutableListOf() + for (i in 0 until parts.length()) { + val p = parts.optJSONObject(i) ?: continue + if (p.has("text")) sb.append(p.optString("text")) + if (p.has("functionCall")) { + val fc = p.optJSONObject("functionCall") ?: continue + val name = fc.optString("name") + val args = fc.optJSONObject("args") ?: JSONObject() + if (name.isNotBlank()) calls.add(AIToolCall(name, args, fc.toString(), null)) + } + } + val out = sb.toString() + if (calls.isEmpty()) ToolCallProtocol.parse(out)?.let { calls.add(it) } + return ToolAwareResponse(out, calls) + } finally { connection.disconnect() } + } + + override fun supportsNativeTools(): Boolean = true + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + clearNative(); nativeToolsActive = true + nativeContents.put(JSONObject().put("role","user").put("parts", JSONArray().put(JSONObject().put("text", prompt)))) + val res = callGeminiNative(nativeContents, composeSystemPrompt(ctx, false), tools) + // store model response + val modelParts = JSONArray() + if (res.text.isNotBlank()) modelParts.put(JSONObject().put("text", res.text)) + res.toolCalls.forEach { tc -> modelParts.put(JSONObject().put("functionCall", JSONObject().put("name", tc.name).put("args", tc.arguments))) } + if (modelParts.length() > 0) nativeContents.put(JSONObject().put("role","model").put("parts", modelParts)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n"){it.result}, null, "kotlin", null, tools) + val parts = JSONArray() + toolResults.forEach { tr -> + parts.put(JSONObject().put("functionResponse", JSONObject().put("name", tr.name).put("response", JSONObject().put("name", tr.name).put("content", tr.result)))) + } + nativeContents.put(JSONObject().put("role","user").put("parts", parts)) + val res = callGeminiNative(nativeContents, composeSystemPrompt(ctx, false), tools) + val modelParts = JSONArray() + if (res.text.isNotBlank()) modelParts.put(JSONObject().put("text", res.text)) + res.toolCalls.forEach { tc -> modelParts.put(JSONObject().put("functionCall", JSONObject().put("name", tc.name).put("args", tc.arguments))) } + if (modelParts.length() > 0) nativeContents.put(JSONObject().put("role","model").put("parts", modelParts)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override fun isInitialized(): Boolean = generativeModel != null } @@ -405,4 +529,4 @@ data class FileModification( data class ConversationMessage( val role: String, val content: String -) \ No newline at end of file +) From b0b7f72193bf1f6939119133ebc6ec7cf0b3829f Mon Sep 17 00:00:00 2001 From: Nabin_sharma <105613345+NabinSharmaitgithub@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:47:32 +0545 Subject: [PATCH 6/6] fix(ai): resolve build errors (duplicate json imports, Kotlin <2.2 continue-in-inline-lambda) - DeepSeek/Grok: remove duplicate org.json JSONArray/JSONObject imports - McpClient/AgentPromptProfile/CustomProviderProfile: replace non-local continue inside ifBlank (requires Kotlin 2.2) with regular if-check for compatibility --- .../java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt | 2 -- .../src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt | 2 -- .../src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt | 2 +- .../com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt | 2 +- .../com/tom/rv2ide/artificial/profiles/CustomProviderProfile.kt | 2 +- 5 files changed, 3 insertions(+), 7 deletions(-) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt index 38d61e3bab..4c93898e87 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt @@ -44,8 +44,6 @@ import kotlinx.coroutines.withContext import java.io.File import java.net.HttpURLConnection import java.net.URL -import org.json.JSONObject -import org.json.JSONArray class DeepSeek : AIAgent { diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt index 9c89741bc0..7721a6b065 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt @@ -44,8 +44,6 @@ import kotlinx.coroutines.withContext import java.io.File import java.net.HttpURLConnection import java.net.URL -import org.json.JSONObject -import org.json.JSONArray class Grok : AIAgent { diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt index ab8c8b55d3..63eeff37b0 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt @@ -31,7 +31,7 @@ class McpClient(private val config: McpServerConfig) { val out = mutableListOf() for (i in 0 until tools.length()) { val t = tools.optJSONObject(i) ?: continue - val name = t.optString("name").ifBlank { continue } + val name = t.optString("name"); if (name.isBlank()) continue val desc = t.optString("description", "") val schema = t.optJSONObject("inputSchema")?.toString() ?: "{\"type\":\"object\",\"properties\":{}}" out.add(AIToolDef(name = "mcp__${safe(config.name)}__${name}", description = "[MCP:${config.name}] $desc", parametersJsonSchema = schema)) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt index 38a10621e1..bdd36d5139 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt @@ -44,7 +44,7 @@ object AgentPromptProfileStore { val profiles = mutableListOf() for (i in 0 until arr.length()) { val p = arr.optJSONObject(i) ?: continue - val name = p.optString("name").ifBlank { continue } + val name = p.optString("name"); if (name.isBlank()) continue fun strList(key: String): List { val a = p.optJSONArray(key) ?: return emptyList(); return (0 until a.length()).map { a.optString(it) }.filter { it.isNotBlank() } } profiles.add(AgentPromptProfile( id = p.optString("id").ifBlank { UUID.randomUUID().toString() }, diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/CustomProviderProfile.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/CustomProviderProfile.kt index 8a1717da7a..91eab878a3 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/CustomProviderProfile.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/CustomProviderProfile.kt @@ -32,7 +32,7 @@ object CustomProviderProfileStore { val profiles = mutableListOf() for (i in 0 until arr.length()) { val p = arr.optJSONObject(i) ?: continue - val name = p.optString("name").ifBlank { continue } + val name = p.optString("name"); if (name.isBlank()) continue profiles.add( CustomProviderProfile( id = p.optString("id").ifBlank { UUID.randomUUID().toString() },