diff --git a/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml b/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml index 7289824a..58824d79 100644 --- a/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml +++ b/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml @@ -39,9 +39,9 @@ android:name="plugin.author" android:value="App Dev for All" /> - + diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt index 9e85a9cc..752fbb8d 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt @@ -61,7 +61,7 @@ class GeminiBackend( private val context: PluginContext, private val promptConfig: () -> GeminiPromptConfig?, ) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend, - EmbeddingBackend, WebSearchBackend { + EmbeddingBackend, WebSearchBackend, ActiveModelReportingBackend { private val scope = CoroutineScope(Dispatchers.IO) @@ -337,6 +337,12 @@ class GeminiBackend( override fun getName(): String = "Gemini API" + /** + * The chat model requests go to, for the Agent's backend tag. Read from preferences like every + * request, so it is never stale; the plugin reports a change through `notifyBackendChanged`. + */ + override fun getActiveModelName(): String = getModelName() + /** * Written for a large cloud model; see [GeminiSystemPrompt] for why the wording belongs here. * Null until the config is loaded or when it cannot render, which ai-core diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt index 5ee78553..fc1249fb 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt @@ -2,7 +2,7 @@ package com.itsaky.androidide.plugins.aiagentgemini.plugin import com.itsaky.androidide.plugins.IPlugin import com.itsaky.androidide.plugins.PluginContext -import com.itsaky.androidide.plugins.PluginLifecycleListener +import com.itsaky.androidide.plugins.ai.LlmBackendRegistration import com.itsaky.androidide.plugins.ai.prompt.AssetPromptConfigSource import com.itsaky.androidide.plugins.aiagentgemini.backend.GeminiBackend import com.itsaky.androidide.plugins.aiagentgemini.preferences.GeminiPreferences @@ -12,8 +12,6 @@ import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.sharedPromptCon import com.itsaky.androidide.plugins.extensions.DocumentationExtension import com.itsaky.androidide.plugins.extensions.PluginTooltipButton import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry -import com.itsaky.androidide.plugins.services.LlmInferenceService -import com.itsaky.androidide.plugins.services.SharedServices /** * Registers the Google Gemini API backend with AI Core's inference router. @@ -26,22 +24,15 @@ class GeminiPlugin : IPlugin, DocumentationExtension { private lateinit var context: PluginContext - /** - * Volatile because [activate] writes it on the loading thread while the host may deliver - * `onPluginActivated` on another: a plain field lets [registerBackend] read null and give up - * without scheduling a retry, leaving the selector permanently empty. - */ + /** The live backend, from [activate] until [deactivate] releases it. */ @Volatile private var backend: GeminiBackend? = null - /** True once [backend] is registered with the router, so re-registration is idempotent. */ - @Volatile private var registered = false + /** Keeps [backend] registered with AI Core across its restarts, and reports setting changes. */ + private lateinit var registration: LlmBackendRegistration companion object { const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentgemini" - /** Provider of [LlmInferenceService]; this plugin is useless without it. */ - private const val AI_CORE_PLUGIN_ID = "com.itsaky.androidide.plugins.aicore" - /** * The whole-plugin entry, and the only one carrying the Tier-3 guide button. Anchored to * the key status line on this backend's settings pane — the one element this plugin always @@ -61,6 +52,9 @@ class GeminiPlugin : IPlugin, DocumentationExtension { const val TOOLTIP_TAG_SETTINGS_GEMINI_EMBEDDING_MODEL = "ai_gemini_embedding_model" const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_gemini_get_free_key" + /** The settings that change what [GeminiBackend.isAvailable] or its model name answers. */ + private val WATCHED_KEYS = setOf(GeminiPreferences.KEY_API_KEY, GeminiPreferences.KEY_MODEL) + @Volatile private var pluginContext: PluginContext? = null @@ -77,31 +71,16 @@ class GeminiPlugin : IPlugin, DocumentationExtension { fun getBackend(): GeminiBackend? = activeBackend } - /** - * Re-registers when AI Core activates. Plugins load in parallel with no ordering, so - * [activate] may run before AI Core has published its service; this closes that race - * instead of polling for it. - */ - private val aiCoreLifecycle = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) { - if (pluginId == AI_CORE_PLUGIN_ID) registerBackend() - } - - override fun onPluginDeactivated(pluginId: String) { - // The router went away and took the registration with it; allow a fresh one. - if (pluginId == AI_CORE_PLUGIN_ID) registered = false - } - - override fun onPluginUninstalled(pluginId: String) { - if (pluginId == AI_CORE_PLUGIN_ID) registered = false - } - } - override fun initialize(context: PluginContext): Boolean { return try { this.context = context // Published for the settings pane, which the hosting screen constructs directly. pluginContext = context + registration = LlmBackendRegistration( + context = context, + preferences = { GeminiPreferences.of(context) }, + watchedKeys = WATCHED_KEYS, + ) context.logger.info("GeminiPlugin: Plugin initialized successfully") true } catch (e: Exception) { @@ -128,15 +107,7 @@ class GeminiPlugin : IPlugin, DocumentationExtension { // Decrypt the key off-thread now, so a main-thread isAvailable() can't say "no key". gemini.warmKeyCache() - - // Listen first, then try: a listener added after a successful attempt would still be - // needed for a later AI Core restart, and one added before costs nothing. - context.addPluginLifecycleListener(aiCoreLifecycle) - if (!registerBackend()) { - context.logger.info( - "GeminiPlugin: AI Core is not active yet; will register when it activates" - ) - } + registration.start(gemini) true } catch (e: Exception) { @@ -145,44 +116,6 @@ class GeminiPlugin : IPlugin, DocumentationExtension { } } - /** - * Registers the Gemini backend with AI Core's router, if the router is reachable. - * - * @return true when the backend is registered (now or already), false when AI Core is absent - */ - private fun registerBackend(): Boolean { - if (registered) return true - val gemini = backend ?: return false - - val service = resolveInferenceService() - if (service == null) { - context.logger.debug("GeminiPlugin: LlmInferenceService not available yet") - return false - } - - return try { - service.registerBackend(gemini) - registered = true - context.logger.info("GeminiPlugin: Registered '${gemini.getId()}' backend with AI Core") - true - } catch (e: Exception) { - context.logger.error("GeminiPlugin: Could not register the Gemini backend", e) - false - } - } - - /** - * Resolves AI Core's router, preferring the process-global registry and falling back to the - * provider-scoped lookup so a registry cleared by another plugin is not fatal. - */ - private fun resolveInferenceService(): LlmInferenceService? = try { - SharedServices.get(LlmInferenceService::class.java) - ?: context.getPluginService(AI_CORE_PLUGIN_ID, LlmInferenceService::class.java) - } catch (e: Exception) { - context.logger.warn("GeminiPlugin: Could not resolve LlmInferenceService: ${e.message}") - null - } - /** Reads and validates the prompt config now, so building a prompt does no disk I/O. */ private fun preloadPromptConfig() { val source = AssetPromptConfigSource(context.androidContext.assets) @@ -210,15 +143,6 @@ class GeminiPlugin : IPlugin, DocumentationExtension { context.logger.info("GeminiPlugin: Deactivating plugin") return try { - context.removePluginLifecycleListener(aiCoreLifecycle) - - val gemini = backend - if (gemini != null && registered) { - resolveInferenceService()?.unregisterBackend(gemini.getId()) - registered = false - context.logger.info("GeminiPlugin: Unregistered '${gemini.getId()}' backend") - } - // A disabled plugin must not keep the decrypted key on the host heap. releaseBackend() sharedPromptConfig.clear() @@ -235,18 +159,15 @@ class GeminiPlugin : IPlugin, DocumentationExtension { * backend. Idempotent, so a [deactivate] followed by [dispose] closes nothing twice. */ private fun releaseBackend() { + if (::registration.isInitialized) registration.stop() backend?.close() backend = null activeBackend = null - registered = false } override fun dispose() { context.logger.info("GeminiPlugin: Disposing plugin") - // deactivate() removes this too; a dispose without one would leave the host holding this. - runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } - releaseBackend() sharedPromptConfig.clear() pluginContext = null diff --git a/plugins/AI-Agent-Local/src/main/AndroidManifest.xml b/plugins/AI-Agent-Local/src/main/AndroidManifest.xml index d24e7570..7bfb9c9b 100644 --- a/plugins/AI-Agent-Local/src/main/AndroidManifest.xml +++ b/plugins/AI-Agent-Local/src/main/AndroidManifest.xml @@ -36,8 +36,9 @@ android:name="plugin.author" android:value="App Dev for All" /> - + diff --git a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt index 0590d949..65b646da 100644 --- a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt +++ b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt @@ -60,7 +60,7 @@ class LocalLlmBackend( private val modelSourceOverride: NativeModelSource? = null, private val engineOverride: ModelResidencyEngine? = null, private val watcherOverride: ModelSourceWatcher? = null, -) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend { +) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ActiveModelReportingBackend { companion object { /** @@ -205,6 +205,21 @@ class LocalLlmBackend( override fun getName(): String = "Local LLM" + /** + * The configured model's name, for the Agent's backend tag: the one the settings pane saved at + * selection, else a plain file path's own name. A `content://` reference with no saved name + * says nothing readable, so it reports none rather than a document id. + */ + override fun getActiveModelName(): String? { + val prefs = LocalLlmPreferences.of(context) + prefs.getString(LocalLlmPreferences.KEY_MODEL_NAME, null)?.trim() + ?.takeIf { it.isNotEmpty() }?.let { return it } + val path = prefs.getString(LocalLlmPreferences.KEY_MODEL_PATH, null)?.trim() + ?.takeIf { it.isNotEmpty() } ?: return null + if (path.startsWith("content:")) return null + return File(path).name.takeIf { it.isNotEmpty() } + } + /** * Whether the user's current selection names this backend. * diff --git a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt index 40c39d71..a44bfe81 100644 --- a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt +++ b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt @@ -2,7 +2,7 @@ package com.itsaky.androidide.plugins.aiagentlocal.plugin import com.itsaky.androidide.plugins.IPlugin import com.itsaky.androidide.plugins.PluginContext -import com.itsaky.androidide.plugins.PluginLifecycleListener +import com.itsaky.androidide.plugins.ai.LlmBackendRegistration import com.itsaky.androidide.plugins.ai.prompt.AssetPromptConfigSource import com.itsaky.androidide.plugins.aiagentlocal.backend.LocalLlmBackend import com.itsaky.androidide.plugins.aiagentlocal.preferences.LocalLlmPreferences @@ -12,8 +12,6 @@ import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.sharedPromptConf import com.itsaky.androidide.plugins.extensions.DocumentationExtension import com.itsaky.androidide.plugins.extensions.PluginTooltipButton import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry -import com.itsaky.androidide.plugins.services.LlmInferenceService -import com.itsaky.androidide.plugins.services.SharedServices /** * Registers the on-device llama.cpp backend with AI Core's inference router. @@ -26,22 +24,15 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { private lateinit var context: PluginContext - /** - * Volatile because [activate] writes it on the loading thread while the host may deliver - * `onPluginActivated` on another: a plain field lets [registerBackend] read null and give up - * without scheduling a retry, leaving the selector permanently empty. - */ + /** The live backend, from [activate] until [deactivate] releases it. */ @Volatile private var backend: LocalLlmBackend? = null - /** True once [backend] is registered with the router, so re-registration is idempotent. */ - @Volatile private var registered = false + /** Keeps [backend] registered with AI Core across its restarts, and reports setting changes. */ + private lateinit var registration: LlmBackendRegistration companion object { const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentlocal" - /** Provider of [LlmInferenceService]; this plugin is useless without it. */ - private const val AI_CORE_PLUGIN_ID = "com.itsaky.androidide.plugins.aicore" - /** * The whole-plugin entry, and the only one carrying the Tier-3 guide button. Anchored to * the engine status line on this backend's settings pane — the one element this plugin @@ -64,6 +55,12 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { const val TOOLTIP_TAG_MEMORY_PROCEED = "ai_local_memory_warning_proceed" const val TOOLTIP_TAG_MEMORY_CANCEL = "ai_local_memory_warning_cancel" + /** The settings that change what [LocalLlmBackend.isAvailable] or its model name answers. */ + private val WATCHED_KEYS = setOf( + LocalLlmPreferences.KEY_MODEL_PATH, + LocalLlmPreferences.KEY_MODEL_NAME, + ) + @Volatile private var pluginContext: PluginContext? = null @@ -71,31 +68,16 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { fun getContext(): PluginContext? = pluginContext } - /** - * Re-registers when AI Core activates. Plugins load in parallel with no ordering, so - * [activate] may run before AI Core has published its service; this closes that race - * instead of polling for it. - */ - private val aiCoreLifecycle = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) { - if (pluginId == AI_CORE_PLUGIN_ID) registerBackend() - } - - override fun onPluginDeactivated(pluginId: String) { - // The router went away and took the registration with it; allow a fresh one. - if (pluginId == AI_CORE_PLUGIN_ID) registered = false - } - - override fun onPluginUninstalled(pluginId: String) { - if (pluginId == AI_CORE_PLUGIN_ID) registered = false - } - } - override fun initialize(context: PluginContext): Boolean { return try { this.context = context // Published for the settings pane, which the hosting screen constructs directly. pluginContext = context + registration = LlmBackendRegistration( + context = context, + preferences = { LocalLlmPreferences.of(context) }, + watchedKeys = WATCHED_KEYS, + ) context.logger.info("LocalLlmPlugin: Plugin initialized successfully") true } catch (e: Exception) { @@ -116,16 +98,9 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { releaseBackend() preloadPromptConfig() - backend = LocalLlmBackend(context, sharedPromptConfig::configIfLoaded) - - // Listen first, then try: a listener added after a successful attempt would still be - // needed for a later AI Core restart, and one added before costs nothing. - context.addPluginLifecycleListener(aiCoreLifecycle) - if (!registerBackend()) { - context.logger.info( - "LocalLlmPlugin: AI Core is not active yet; will register when it activates" - ) - } + val local = LocalLlmBackend(context, sharedPromptConfig::configIfLoaded) + backend = local + registration.start(local) true } catch (e: Exception) { @@ -134,44 +109,6 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { } } - /** - * Registers the local backend with AI Core's router, if the router is reachable. - * - * @return true when the backend is registered (now or already), false when AI Core is absent - */ - private fun registerBackend(): Boolean { - if (registered) return true - val local = backend ?: return false - - val service = resolveInferenceService() - if (service == null) { - context.logger.debug("LocalLlmPlugin: LlmInferenceService not available yet") - return false - } - - return try { - service.registerBackend(local) - registered = true - context.logger.info("LocalLlmPlugin: Registered '${local.getId()}' backend with AI Core") - true - } catch (e: Exception) { - context.logger.error("LocalLlmPlugin: Could not register the local backend", e) - false - } - } - - /** - * Resolves AI Core's router, preferring the process-global registry and falling back to the - * provider-scoped lookup so a registry cleared by another plugin is not fatal. - */ - private fun resolveInferenceService(): LlmInferenceService? = try { - SharedServices.get(LlmInferenceService::class.java) - ?: context.getPluginService(AI_CORE_PLUGIN_ID, LlmInferenceService::class.java) - } catch (e: Exception) { - context.logger.warn("LocalLlmPlugin: Could not resolve LlmInferenceService: ${e.message}") - null - } - /** Reads and validates the prompt config now, so building a prompt does no disk I/O. */ private fun preloadPromptConfig() { val source = AssetPromptConfigSource(context.androidContext.assets) @@ -199,15 +136,6 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { context.logger.info("LocalLlmPlugin: Deactivating plugin") return try { - context.removePluginLifecycleListener(aiCoreLifecycle) - - val local = backend - if (local != null && registered) { - resolveInferenceService()?.unregisterBackend(local.getId()) - registered = false - context.logger.info("LocalLlmPlugin: Unregistered '${local.getId()}' backend") - } - // A disabled plugin must not keep the loaded model resident in host RAM. releaseBackend() sharedPromptConfig.clear() @@ -225,17 +153,14 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { * re-enable still infers. */ private fun releaseBackend() { + if (::registration.isInitialized) registration.stop() backend?.close() backend = null - registered = false } override fun dispose() { context.logger.info("LocalLlmPlugin: Disposing plugin") - // deactivate() removes this too; a dispose without one would leave the host holding this. - runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } - releaseBackend() sharedPromptConfig.clear() pluginContext = null diff --git a/plugins/AI-Agent-MCP/src/main/AndroidManifest.xml b/plugins/AI-Agent-MCP/src/main/AndroidManifest.xml index 09d9176e..9f725531 100644 --- a/plugins/AI-Agent-MCP/src/main/AndroidManifest.xml +++ b/plugins/AI-Agent-MCP/src/main/AndroidManifest.xml @@ -39,9 +39,9 @@ android:name="plugin.author" android:value="App Dev for All" /> - + diff --git a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPlugin.kt b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPlugin.kt index e9493e56..149a17df 100644 --- a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPlugin.kt +++ b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPlugin.kt @@ -7,6 +7,7 @@ import com.itsaky.androidide.plugins.aiagentmcp.R import com.itsaky.androidide.plugins.aiagentmcp.client.McpConnections import com.itsaky.androidide.plugins.aiagentmcp.settings.McpServerStore import com.itsaky.androidide.plugins.aiagentmcp.settings.McpSettingsFragment +import com.itsaky.androidide.plugins.aiagentmcp.tools.McpServerHealth import com.itsaky.androidide.plugins.aiagentmcp.tools.McpToolCatalog import com.itsaky.androidide.plugins.aiagentmcp.tools.McpToolSource import com.itsaky.androidide.plugins.extensions.DocumentationExtension @@ -20,7 +21,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -70,6 +75,18 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { /** Must match `plugin.id` in AndroidManifest.xml; also this source's provider id. */ const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentmcp" + /** + * How often the health probe wakes. A server known to be broken is retried every time, so + * its tag clears within this long of it coming back. + */ + internal const val PROBE_INTERVAL_MS = 20_000L + + /** + * Probe cycles between re-checks of a server that is working: often enough that one going + * down is flagged within a minute, rarely enough not to spend a phone's radio on it. + */ + internal const val HEALTHY_PROBE_EVERY = 3 + /** Provider of [ToolSourceRegistry]; this plugin contributes nothing without it. */ private const val AI_CORE_PLUGIN_ID = "com.itsaky.androidide.plugins.aicore" @@ -128,6 +145,19 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { resolveToolSourceRegistry()?.notifyToolsChanged(PLUGIN_ID) } + /** + * Tells the agent a server went down or came back. Status only: the tool list is unchanged, + * so the agent redraws its tags without rebuilding its tool set. + */ + private val healthChanged: () -> Unit = { + try { + if (registered) resolveToolSourceRegistry()?.notifyToolSourceStatusChanged(PLUGIN_ID) + } catch (e: Throwable) { + // An AI Core built before contract 2 has no such method; its tags cannot show health. + context.logger.debug("McpPlugin: could not report a health change: ${e.message}") + } + } + override fun initialize(context: PluginContext): Boolean { this.context = context pluginContext = context @@ -143,6 +173,7 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { activationJob = active.coroutineContext[Job] toolSource = McpToolSource() McpServerStore.addChangeListener(settingsChanged) + McpServerHealth.addChangeListener(healthChanged) context.addPluginLifecycleListener(aiCoreLifecycle) if (!registerToolSource()) { @@ -154,6 +185,7 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { active.launch { val refreshed = McpToolCatalog.refreshAll { isActive } if (refreshed > 0 && isActive) settingsChanged() + probeHealth { isActive } } true } catch (e: Exception) { @@ -164,6 +196,7 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { override fun deactivate(): Boolean = try { context.removePluginLifecycleListener(aiCoreLifecycle) McpServerStore.removeChangeListener(settingsChanged) + McpServerHealth.removeChangeListener(healthChanged) unregisterToolSource() // Before the connections are closed: an in-flight refresh would otherwise repopulate the // catalogue and the session map straight after they were cleared. @@ -178,6 +211,7 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { override fun dispose() { runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } McpServerStore.removeChangeListener(settingsChanged) + McpServerHealth.removeChangeListener(healthChanged) unregisterToolSource() stopScope() releaseConnections() @@ -185,6 +219,44 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { context.logger.info("McpPlugin: disposed") } + /** + * Re-asks each enabled server, so its tag in the agent tracks it going down and coming back + * without the user pressing Connect. A broken or never-reached server is asked every cycle, a + * working one every [HEALTHY_PROBE_EVERY]th, and one that refused its credential not until the + * user changes it. Runs until [keepGoing] turns false. A server whose cached tools changed, one + * coming back with the same names included, makes the agent re-read. + * + * Servers are asked in parallel, so one that hangs until its timeout delays no other's tag. + * + * @param keepGoing false once this activation is over; checked before each probe, since a probe + * blocks on a socket with no suspension point, like [McpToolCatalog.refreshAll]. + */ + private suspend fun probeHealth(keepGoing: () -> Boolean) { + var cycle = 0 + while (keepGoing()) { + delay(PROBE_INTERVAL_MS) + cycle++ + val due = McpServerStore.servers().filter { server -> + val health = McpServerHealth.of(server.id) + server.enabled && health?.refused != true && (cycle % HEALTHY_PROBE_EVERY == 0 || + health?.state != McpServerHealth.State.AVAILABLE) + } + val toolsChanged = coroutineScope { + due.map { server -> + async { + if (!keepGoing()) return@async false + val before = McpToolCatalog.tools(server.id) + // Recorded by connect itself; a failure here is what the tag already shows. + runCatching { McpToolCatalog.connect(server) } + McpToolCatalog.tools(server.id) != before + } + }.awaitAll().any { it } + } + // The stored names may be unchanged, so the store's own notification cannot be relied on. + if (toolsChanged && keepGoing()) settingsChanged() + } + } + /** A fresh scope for this activation; the previous one is cancelled, never reused. */ private fun newScope() = CoroutineScope(SupervisorJob() + Dispatchers.IO) diff --git a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStore.kt b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStore.kt index e00e6b38..1cd47b47 100644 --- a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStore.kt +++ b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStore.kt @@ -270,12 +270,14 @@ object McpServerStore { fun setKnownTools(id: String, toolNames: List) { synchronized(lock) { val server = servers().firstOrNull { it.id == id } ?: return - upsertLocked( - server.copy( - knownTools = toolNames, - enabledTools = server.enabledTools.filterTo(mutableSetOf()) { it in toolNames }, - ) + val updated = server.copy( + knownTools = toolNames, + enabledTools = server.enabledTools.filterTo(mutableSetOf()) { it in toolNames }, ) + // The health probe re-lists every server each cycle; an unchanged list must not make + // the agent rebuild its whole tool set every minute. + if (updated == server) return + upsertLocked(updated) } fireChanged() } diff --git a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsViewModel.kt b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsViewModel.kt index 9e5f1ee1..aa5859aa 100644 --- a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsViewModel.kt +++ b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsViewModel.kt @@ -10,6 +10,7 @@ import com.itsaky.androidide.plugins.aiagentmcp.client.McpTool import com.itsaky.androidide.plugins.aiagentmcp.errors.McpErrorFormatter import com.itsaky.androidide.plugins.aiagentmcp.security.UnavailableSecretException import com.itsaky.androidide.plugins.aiagentmcp.security.UnreadableSecretException +import com.itsaky.androidide.plugins.aiagentmcp.tools.McpServerHealth import com.itsaky.androidide.plugins.aiagentmcp.tools.McpToolCatalog import com.itsaky.androidide.plugins.security.KeystoreSecretStore import kotlinx.coroutines.Dispatchers @@ -171,6 +172,7 @@ class McpSettingsViewModel( val headersStored = headers?.let { McpServerStore.setHeaders(server.id, it) } ?: true // A credential change has to invalidate the session, or the old one keeps working. McpConnections.invalidate(server.id) + McpServerHealth.credentialChanged(server.id) val failure = when { !tokenStored -> string(R.string.mcp_token_save_failed) !headersStored -> string(R.string.mcp_headers_save_failed) @@ -200,6 +202,7 @@ class McpSettingsViewModel( val headersCleared = McpServerStore.setHeaders(id, emptyMap()) // The session was keyed by the credential it no longer has. McpConnections.invalidate(id) + McpServerHealth.credentialChanged(id) tokenCleared && headersCleared } onDone(cleared) @@ -214,8 +217,9 @@ class McpSettingsViewModel( viewModelScope.launch { withContext(Dispatchers.IO) { McpConnections.invalidate(id) - McpToolCatalog.forget(id) + // Removed first, so a probe finishing in between sees it gone and records nothing. McpServerStore.remove(id) + McpToolCatalog.forget(id) } reload() } diff --git a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpServerHealth.kt b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpServerHealth.kt new file mode 100644 index 00000000..16872204 --- /dev/null +++ b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpServerHealth.kt @@ -0,0 +1,106 @@ +package com.itsaky.androidide.plugins.aiagentmcp.tools + +import android.util.Log +import com.itsaky.androidide.plugins.aiagentmcp.logging.LOG_PREFIX +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +private const val TAG = "$LOG_PREFIX.McpServerHealth" + +/** + * Whether each configured server answered the last time it was asked. + * + * The agent's tag row reads this on the UI thread, and the contract forbids probing a server + * there, so the answer has to come from memory. It is written wherever this plugin already talks + * to a server — a handshake, a tool listing, a tool call — and by the plugin's periodic probe. + */ +object McpServerHealth { + + /** How a server is doing, in this plugin's own terms; [McpToolSource] maps it onto the host's. */ + enum class State { CONNECTING, AVAILABLE, DEGRADED } + + /** + * @property state the server's state. + * @property message one sentence for the user when [state] is [State.DEGRADED], else null. + * @property refused the server refused the credential, which only the user can fix. + */ + data class Health(val state: State, val message: String? = null, val refused: Boolean = false) + + private val byServer = ConcurrentHashMap() + private val listeners = CopyOnWriteArrayList<() -> Unit>() + + /** + * @param serverId the server. + * @return what is known of it, or null before it was ever asked. + */ + fun of(serverId: String): Health? = byServer[serverId] + + /** + * Records a first attempt under way. Only for a server with no answer yet: a re-probe of a + * known server keeps its last answer until the new one lands, so the tag does not flicker. + */ + fun connectingIfUnknown(serverId: String) { + if (byServer.putIfAbsent(serverId, Health(State.CONNECTING)) == null) fireChanged() + } + + /** + * Undoes [connectingIfUnknown] for a first attempt that was cancelled, so the server reads as + * never asked. An answer recorded meanwhile is kept. + */ + fun cancelConnecting(serverId: String) { + if (byServer.remove(serverId, Health(State.CONNECTING))) fireChanged() + } + + /** Records that the server answered. */ + fun available(serverId: String) = set(serverId, Health(State.AVAILABLE)) + + /** + * Records that the server could not be used. + * @param message why, for the user, in the words the settings pane would use. + * @param refused whether it refused the credential; see [Health.refused]. + */ + fun degraded(serverId: String, message: String, refused: Boolean = false) = + set(serverId, Health(State.DEGRADED, message, refused)) + + /** Lets the probe ask a server that refused its credential again, once the user changed it. */ + fun credentialChanged(serverId: String) { + byServer.computeIfPresent(serverId) { _, health -> health.copy(refused = false) } + } + + /** Forgets a server that was removed. */ + fun forget(serverId: String) { + if (byServer.remove(serverId) != null) fireChanged() + } + + /** Forgets everything, for the plugin shutting down. */ + fun clear() { + if (byServer.isEmpty()) return + byServer.clear() + fireChanged() + } + + /** Registers [listener], called after any server's health changes. */ + fun addChangeListener(listener: () -> Unit) { + listeners.add(listener) + } + + /** Removes a listener added by [addChangeListener]. */ + fun removeChangeListener(listener: () -> Unit) { + listeners.remove(listener) + } + + /** Stores [health], telling the listeners only when it differs from what was there. */ + private fun set(serverId: String, health: Health) { + if (byServer.put(serverId, health) != health) fireChanged() + } + + private fun fireChanged() { + for (listener in listeners) { + try { + listener() + } catch (e: Throwable) { + Log.e(TAG, "A health change listener threw", e) + } + } + } +} diff --git a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolCatalog.kt b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolCatalog.kt index a96faacc..c5828aeb 100644 --- a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolCatalog.kt +++ b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolCatalog.kt @@ -4,7 +4,10 @@ import android.util.Log import com.itsaky.androidide.plugins.aiagentmcp.client.McpConnections import com.itsaky.androidide.plugins.aiagentmcp.client.McpProtocolException import com.itsaky.androidide.plugins.aiagentmcp.client.McpTool +import com.itsaky.androidide.plugins.aiagentmcp.errors.McpErrorFormatter +import com.itsaky.androidide.plugins.aiagentmcp.errors.McpFailure import com.itsaky.androidide.plugins.aiagentmcp.logging.LOG_PREFIX +import com.itsaky.androidide.plugins.aiagentmcp.plugin.McpPlugin import com.itsaky.androidide.plugins.aiagentmcp.settings.McpServer import com.itsaky.androidide.plugins.aiagentmcp.settings.McpServerStore import com.itsaky.androidide.plugins.aiagentmcp.transport.JsonRpc @@ -43,13 +46,50 @@ object McpToolCatalog { * Handshakes with one server, reads its tool list and caches it. * * Blocking, so call it off the main thread. The stored known-tool names are updated too, which - * is what drops toggles for tools the server no longer offers. + * is what drops toggles for tools the server no longer offers, and so is [McpServerHealth], + * which is how the agent's tag for this server learns it went down or came back. * * @param server the server to ask. * @return its name and the tools it listed, empty for a server that offers no catalogue. * @throws java.io.IOException when the server cannot be reached or refuses the handshake. */ fun connect(server: McpServer): Listing { + McpServerHealth.connectingIfUnknown(server.id) + val listing = try { + list(server) + } catch (e: Throwable) { + recordFailure(server, e) + throw e + } + // Deleted while the handshake was in flight: recording it would resurrect its entry. + if (isConfigured(server.id)) McpServerHealth.available(server.id) + return listing + } + + /** + * Marks [server] degraded for [error], unless the error is a call being cancelled — the server + * did nothing wrong, and a stopped run must not paint it broken, nor leave it connecting. + * + * @param server the server that failed. + * @param error what it failed with. + */ + fun recordFailure(server: McpServer, error: Throwable) { + val failure = McpErrorFormatter.classify(error) + if (failure == McpFailure.Cancelled) { + McpServerHealth.cancelConnecting(server.id) + return + } + if (!isConfigured(server.id)) return + val context = McpPlugin.getContext()?.androidContext + val refused = failure == McpFailure.TokenRefused || failure == McpFailure.Forbidden + McpServerHealth.degraded(server.id, McpErrorFormatter.format(context, server.name, error), refused) + } + + private fun isConfigured(serverId: String): Boolean = + McpServerStore.servers().any { it.id == serverId } + + /** The handshake-and-list itself; see [connect]. */ + private fun list(server: McpServer): Listing { // Kept, not just its tools: the name the handshake returned lands on the session. val session = McpConnections.session(server) // Explicit, so a catalogue refused below is told apart from a refused connection. @@ -97,13 +137,15 @@ object McpToolCatalog { return refreshed } - /** Forgets a server's tools, for one that was removed or edited. */ + /** Forgets a server's tools and health, for one that was removed. */ fun forget(serverId: String) { toolsByServer.remove(serverId) + McpServerHealth.forget(serverId) } /** Forgets everything, for the plugin shutting down. */ fun clear() { toolsByServer.clear() + McpServerHealth.clear() } } diff --git a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolSource.kt b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolSource.kt index 80af1e32..f4e04bf3 100644 --- a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolSource.kt +++ b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolSource.kt @@ -10,6 +10,7 @@ import com.itsaky.androidide.plugins.aiagentmcp.logging.LOG_PREFIX import com.itsaky.androidide.plugins.aiagentmcp.plugin.McpPlugin import com.itsaky.androidide.plugins.aiagentmcp.settings.McpServer import com.itsaky.androidide.plugins.aiagentmcp.settings.McpServerStore +import com.itsaky.androidide.plugins.services.CapabilityStatus import com.itsaky.androidide.plugins.services.ToolSourceRegistry import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap @@ -25,7 +26,9 @@ private const val TAG = "$LOG_PREFIX.McpToolSource" * which would exhaust a phone-sized context window on its own, so the toggle defaults to off and * this class never widens it. */ -class McpToolSource : ToolSourceRegistry.ToolSource { +class McpToolSource : + ToolSourceRegistry.StatusReportingToolSource, + ToolSourceRegistry.GroupedToolSource { /** Calls in flight, so a stopped agent run can drop the socket instead of waiting it out. */ private val inFlight = ConcurrentHashMap>() @@ -59,6 +62,39 @@ class McpToolSource : ToolSourceRegistry.ToolSource { ) } + /** + * One group per enabled server, so the agent shows each server as its own tag. A server that + * is enabled but unreachable offers no tools and keeps its group, marked degraded. + */ + override fun getToolGroups(): List { + val namesByServer = exposedTools().groupBy({ it.server.id }, { it.name }) + return McpServerStore.servers().filter { it.enabled }.map { server -> + Group( + id = server.id, + displayName = server.name, + toolNames = namesByServer[server.id].orEmpty(), + health = McpServerHealth.of(server.id), + ) + } + } + + /** The worst of the enabled servers' states: one broken server is worth flagging on the whole. */ + override fun getStatus(): CapabilityStatus { + val states = enabledHealth().map { it?.state ?: McpServerHealth.State.CONNECTING } + return when { + McpServerHealth.State.DEGRADED in states -> CapabilityStatus.DEGRADED + McpServerHealth.State.CONNECTING in states -> CapabilityStatus.CONNECTING + else -> CapabilityStatus.AVAILABLE + } + } + + /** The first broken server's reason; each group carries its own. */ + override fun getStatusMessage(): String? = + enabledHealth().firstOrNull { it?.state == McpServerHealth.State.DEGRADED }?.message + + private fun enabledHealth(): List = + McpServerStore.servers().filter { it.enabled }.map { McpServerHealth.of(it.id) } + override fun invoke( invocation: ToolSourceRegistry.ToolInvocation, ): CompletableFuture { @@ -111,9 +147,12 @@ class McpToolSource : ToolSourceRegistry.ToolSource { } finally { liveSessions.remove(callId) } + // A reply of any kind, a tool's own failure included, means the server is answering. + McpServerHealth.available(server.id) Outcome(result.success, result.text, result.errorMessage) } catch (e: Throwable) { Log.w(TAG, "Tool '${tool.name}' on '${server.name}' failed", e) + McpToolCatalog.recordFailure(server, e) val context = McpPlugin.getContext()?.androidContext Outcome(false, "", McpErrorFormatter.format(context, server.name, e)) } @@ -209,6 +248,27 @@ class McpToolSource : ToolSourceRegistry.ToolSource { override fun isReadOnly(): Boolean = false } + /** + * One server's tools, as the host contract describes a group. A server never asked yet reads + * as connecting rather than working: the tag should not promise tools that may not answer. + */ + private class Group( + private val id: String, + private val displayName: String, + private val toolNames: List, + private val health: McpServerHealth.Health?, + ) : ToolSourceRegistry.ToolGroup { + override fun getId(): String = id + override fun getDisplayName(): String = displayName + override fun getToolNames(): List = toolNames + override fun getStatus(): CapabilityStatus = when (health?.state) { + McpServerHealth.State.AVAILABLE -> CapabilityStatus.AVAILABLE + McpServerHealth.State.DEGRADED -> CapabilityStatus.DEGRADED + McpServerHealth.State.CONNECTING, null -> CapabilityStatus.CONNECTING + } + override fun getStatusMessage(): String? = health?.message + } + /** One outcome, as the host contract describes it. */ private class Outcome( private val success: Boolean, diff --git a/plugins/AI-Agent-MCP/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpServerHealthTest.kt b/plugins/AI-Agent-MCP/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpServerHealthTest.kt new file mode 100644 index 00000000..cf9c2967 --- /dev/null +++ b/plugins/AI-Agent-MCP/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpServerHealthTest.kt @@ -0,0 +1,98 @@ +package com.itsaky.androidide.plugins.aiagentmcp.tools + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * Tests [McpServerHealth], which the agent's per-server tags read: a change is announced once, + * an unchanged answer is not, and a re-probe of a known server never flickers it to connecting. + */ +class McpServerHealthTest { + + private companion object { + const val SERVER = "server-1" + } + + private var changes = 0 + private val listener: () -> Unit = { changes++ } + + @Before + fun setUp() { + McpServerHealth.clear() + McpServerHealth.addChangeListener(listener) + } + + @After + fun tearDown() { + McpServerHealth.removeChangeListener(listener) + McpServerHealth.clear() + } + + @Test + fun givenAnUnknownServer_whenAFirstAttemptStarts_thenItReadsAsConnecting() { + McpServerHealth.connectingIfUnknown(SERVER) + + assertEquals(McpServerHealth.State.CONNECTING, McpServerHealth.of(SERVER)?.state) + assertEquals(1, changes) + } + + @Test + fun givenAWorkingServer_whenItIsProbedAgain_thenItStaysAvailable() { + McpServerHealth.available(SERVER) + + McpServerHealth.connectingIfUnknown(SERVER) + + assertEquals(McpServerHealth.State.AVAILABLE, McpServerHealth.of(SERVER)?.state) + assertEquals(1, changes) + } + + @Test + fun givenADegradedServer_whenItAnswersAgain_thenTheReasonClears() { + McpServerHealth.degraded(SERVER, "Docs is unreachable.") + + McpServerHealth.available(SERVER) + + assertEquals(McpServerHealth.Health(McpServerHealth.State.AVAILABLE), McpServerHealth.of(SERVER)) + assertEquals(2, changes) + } + + @Test + fun givenTheSameAnswerTwice_whenRecorded_thenListenersHearItOnce() { + McpServerHealth.degraded(SERVER, "Docs is unreachable.") + McpServerHealth.degraded(SERVER, "Docs is unreachable.") + + assertEquals(1, changes) + } + + @Test + fun givenARemovedServer_whenForgotten_thenNothingIsKnownOfIt() { + McpServerHealth.available(SERVER) + + McpServerHealth.forget(SERVER) + + assertNull(McpServerHealth.of(SERVER)) + } + + @Test + fun givenAFirstAttemptUnderWay_whenItIsCancelled_thenTheServerReadsAsNeverAsked() { + McpServerHealth.connectingIfUnknown(SERVER) + + McpServerHealth.cancelConnecting(SERVER) + + assertNull(McpServerHealth.of(SERVER)) + assertEquals(2, changes) + } + + @Test + fun givenAKnownAnswer_whenACancelledAttemptIsUndone_thenTheAnswerIsKept() { + McpServerHealth.degraded(SERVER, "Docs is unreachable.") + + McpServerHealth.cancelConnecting(SERVER) + + assertEquals(McpServerHealth.State.DEGRADED, McpServerHealth.of(SERVER)?.state) + assertEquals(1, changes) + } +} diff --git a/plugins/AI-Agent-OpenAI/src/main/AndroidManifest.xml b/plugins/AI-Agent-OpenAI/src/main/AndroidManifest.xml index c5ea9834..c5edba4c 100644 --- a/plugins/AI-Agent-OpenAI/src/main/AndroidManifest.xml +++ b/plugins/AI-Agent-OpenAI/src/main/AndroidManifest.xml @@ -43,9 +43,9 @@ android:name="plugin.author" android:value="App Dev for All" /> - + diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt index 9332b756..247ae6ae 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt @@ -6,7 +6,9 @@ import android.os.Looper import android.util.Log import android.widget.Toast import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.ai.LlmBackendRegistration import com.itsaky.androidide.plugins.aiagentopenai.R +import com.itsaky.androidide.plugins.aiagentopenai.errors.CredentialFailure import com.itsaky.androidide.plugins.aiagentopenai.errors.CredentialFailureLog import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiErrorFormatter import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiFailure @@ -20,7 +22,11 @@ import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptCon import com.itsaky.androidide.plugins.aiagentopenai.security.ApiKeyCache import com.itsaky.androidide.plugins.aiagentopenai.settings.BaseUrlPolicy import com.itsaky.androidide.plugins.aiagentopenai.settings.BaseUrlResult +import com.itsaky.androidide.plugins.aiagentopenai.settings.ServerPresets +import com.itsaky.androidide.plugins.services.CapabilityStatus +import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.LlmInferenceService.* +import com.itsaky.androidide.plugins.services.SharedServices import java.io.BufferedReader import java.io.IOException import java.net.HttpURLConnection @@ -32,6 +38,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.DisposableHandle import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -59,17 +66,35 @@ private const val TAG = "$LOG_PREFIX.AgentTrace" * [ApiKeyCache]'s, and the wording of a failure is [OpenAiFailureMessages]'. * * @param promptConfig the loaded prompt config, or null while it loads; must return without blocking + * @param onStatusChanged told when [getStatus] changes, to report it through `notifyBackendChanged`. */ class OpenAiBackend( private val context: PluginContext, private val promptConfig: () -> OpenAiPromptConfig?, + onStatusChanged: () -> Unit = {}, ) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend, - EmbeddingBackend, WebSearchBackend { + EmbeddingBackend, WebSearchBackend, StatusReportingBackend, ActiveModelReportingBackend { private val scope = CoroutineScope(Dispatchers.IO) private val http = OpenAiHttpClient() + /** For [checkServer]: a server that is up answers `/models` at once, so it waits far less. */ + private val probeHttp = OpenAiHttpClient(connectTimeoutMs = PROBE_TIMEOUT_MS) + + private val health = ServerHealth(onStatusChanged) + + @Volatile + private var probeJob: Job? = null + + /** Restarts [checkServer] when the user selects this backend again, if its last check failed. */ + private val selectionWatch = BackendChangeListener { id -> + val reading = health.readingFor(getBaseUrl()) + if (id == BACKEND_ID && probeJob?.isActive != true && + reading.status != CapabilityStatus.AVAILABLE && reading.problem != ServerProblem.KEY_REFUSED + ) checkServer() + } + private val keyCache = ApiKeyCache(::openAiPrefs, OpenAiPreferences.KEY_API_KEY, context.logger, scope) @@ -123,6 +148,11 @@ class OpenAiBackend( /** Model-catalog endpoint. Optional: many compatible servers do not implement it. */ private const val MODELS_PATH = "/models" + + private const val PROBE_TIMEOUT_MS = 5_000 + + /** How often an unreachable server is checked again, so starting it turns the tag green. */ + private const val RECHECK_INTERVAL_MS = 30_000L } /** This plugin's own settings, written by its settings pane and read here at request time. */ @@ -255,14 +285,110 @@ class OpenAiBackend( /** Falls back to a literal: an empty name would be an unlabelled row in the selector. */ override fun getName(): String = configLabel(R.string.openai_backend_name, fallback = "OpenAI") + /** + * The chat model requests go to, with the server it runs on when that is not OpenAI's own. AI + * Core puts this after [getName] with its own separator, so the server is bracketed, not dotted. + */ + override fun getActiveModelName(): String { + val model = getModelName() + val server = serverLabel() ?: return model + return configLabel(R.string.openai_model_on_server, args = arrayOf(model, server)) + .ifEmpty { model } + } + + /** + * Which server the tag should name beside the model: the preset's name, or the host of a URL + * no preset offers. Null for OpenAI's own API, which [getName] already names. + */ + private fun serverLabel(): String? { + val baseUrl = getBaseUrl() + if (baseUrl == BaseUrlPolicy.DEFAULT_BASE_URL) return null + val preset = ServerPresets.presetFor(baseUrl) ?: return BaseUrlPolicy.authorityOf(baseUrl) + return configLabel(preset.labelRes).takeIf { it.isNotEmpty() } + } + + /** Whether the configured server answered its last check; see [checkServer]. */ + override fun getStatus(): CapabilityStatus = health.readingFor(getBaseUrl()).status + + /** Called across the plugin boundary, so a failed lookup is no reason rather than a throw. */ + override fun getStatusMessage(): String? { + val reading = health.readingFor(getBaseUrl()) + val problem = reading.problem ?: return null + return try { + val res = context.androidContext + when (problem) { + ServerProblem.UNREACHABLE -> + res.getString(R.string.openai_error_server_not_running, reading.baseUrl) + ServerProblem.KEY_REFUSED -> res.getString(CredentialFailure.KeyRefused.messageRes) + ServerProblem.SERVER_ERROR -> + res.getString(R.string.openai_error_service_unavailable, reading.httpStatus ?: 0) + } + } catch (e: Exception) { + context.logger.error("OpenAiBackend: could not resolve the status message", e) + null + } + } + + /** + * Asks the configured server for `/models`, off-thread, and records whether it answered. + * While it stays unreachable or failing and this backend stays selected, it is asked again every + * [RECHECK_INTERVAL_MS]; a refused key is not, since only the user can fix it. Replaces a check + * already running. + */ + fun checkServer() { + if (!scope.isActive) return + // Here rather than once: AI Core calls back through onRegistered after each of its restarts. + inferenceService()?.addBackendChangeListener(selectionWatch) + probeJob?.cancel() + probeJob = scope.launch { + while (isActive) { + // Not set up is reported as such by isAvailable; asking OpenAI with no key is noise. + if (!isAvailable() || !isSelected()) return@launch + val reading = probe(getBaseUrl()) + // The probe blocks on a socket, so close() may have landed while it waited. + if (!isActive) return@launch + health.record(reading) + val retry = reading.problem == ServerProblem.UNREACHABLE || + reading.problem == ServerProblem.SERVER_ERROR + if (!retry) return@launch + delay(RECHECK_INTERVAL_MS) + } + } + } + + /** Whether AI Core routes to this backend; with nothing selected yet it may, so that counts. */ + private fun isSelected(): Boolean = + inferenceService()?.preferredBackendId.let { it == null || it == BACKEND_ID } + + private fun inferenceService(): LlmInferenceService? = runCatching { + SharedServices.get(LlmInferenceService::class.java) ?: context.getPluginService( + LlmBackendRegistration.AI_CORE_PLUGIN_ID, LlmInferenceService::class.java, + ) + }.getOrNull() + + private fun probe(baseUrl: String): HealthReading = try { + probeHttp.get(baseUrl + MODELS_PATH, readApiKeyOrBlank()) + HealthReading.available(baseUrl) + } catch (e: OpenAiHttpException) { + HealthReading.ofHttpStatus(baseUrl, e.statusCode) + } catch (e: IOException) { + context.logger.debug("OpenAiBackend: $baseUrl did not answer: ${e.message}") + HealthReading.unreachable(baseUrl) + } + /** * Resolves a label against this plugin's own resources, degrading rather than throwing — * [getName] is called across the plugin boundary. * * @param fallback returned when the lookup fails + * @param args the format arguments, for a resource that takes any */ - private fun configLabel(resId: Int, fallback: String = ""): String = try { - context.androidContext.getString(resId) + private fun configLabel( + resId: Int, + fallback: String = "", + args: Array = emptyArray(), + ): String = try { + context.androidContext.getString(resId, *args) } catch (e: Exception) { context.logger.error("OpenAiBackend: could not resolve label $resId", e) fallback @@ -820,15 +946,29 @@ class OpenAiBackend( sse: Boolean = false, onConnected: (HttpURLConnection) -> Unit = {}, readResponse: (BufferedReader) -> T, - ): T = http.post( - url = getBaseUrl() + CHAT_COMPLETIONS_PATH, - apiKey = readApiKeyOrBlank(), - body = body, - sse = sse, - onConnected = onConnected, - onAccepted = { credentialFailures.clear() }, - readResponse = readResponse, - ) + ): T { + val baseUrl = getBaseUrl() + return try { + http.post( + url = baseUrl + CHAT_COMPLETIONS_PATH, + apiKey = readApiKeyOrBlank(), + body = body, + sse = sse, + onConnected = onConnected, + onAccepted = { + credentialFailures.clear() + health.record(HealthReading.available(baseUrl)) + }, + readResponse = readResponse, + ) + } catch (e: OpenAiHttpException) { + throw e + } catch (e: IOException) { + // Also how Stop lands, which no reading should describe; a fresh check says which it was. + checkServer() + throw e + } + } /** * List what the configured server offers, split into the models each picker may show. @@ -927,7 +1067,10 @@ class OpenAiBackend( /** Release all resources: cancel the backend scope, any in-flight request, and the key cache. */ fun close() { + inferenceService()?.removeBackendChangeListener(selectionWatch) currentJob?.cancel() + probeJob?.cancel() + probeJob = null scope.cancel() keyCache.clear() } diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocol.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocol.kt index f1f38333..c042707d 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocol.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocol.kt @@ -75,15 +75,21 @@ internal object OpenAiToolProtocol { /** * The `parameters` value for a tool. * - * An empty schema becomes a bare object rather than being omitted: omitting `parameters` - * declares a tool that takes none, and the model would then call it with nothing. + * An empty schema becomes an object with no properties rather than being omitted: omitting + * `parameters` declares a tool that takes none, and the model would then call it with nothing. + * `properties` is always present on an object schema, since LM Studio refuses the whole + * request when one tool's `parameters` lacks it. * * @param schema the tool's JSON Schema, empty when it publishes none. * @return the schema to declare. */ fun parametersJson(schema: Map?): JSONObject { - if (schema.isNullOrEmpty()) return JSONObject().put("type", "object") - return schemaJson(schema, MAX_SCHEMA_DEPTH) + val json = if (schema.isNullOrEmpty()) JSONObject() else schemaJson(schema, MAX_SCHEMA_DEPTH) + if (!json.has("type")) json.put("type", "object") + if (json.optString("type") == "object" && json.optJSONObject("properties") == null) { + json.put("properties", JSONObject()) + } + return json } /** diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/ServerHealth.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/ServerHealth.kt new file mode 100644 index 00000000..52e3b055 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/ServerHealth.kt @@ -0,0 +1,77 @@ +package com.itsaky.androidide.plugins.aiagentopenai.backend + +import com.itsaky.androidide.plugins.services.CapabilityStatus + +/** Why a server check failed; each is different advice for the user. */ +internal enum class ServerProblem { + /** Nothing answered: the server is not running, or this device cannot reach it. */ + UNREACHABLE, + + /** The server answered and refused the stored key. */ + KEY_REFUSED, + + /** The server answered with a 5xx. */ + SERVER_ERROR, +} + +/** + * One check of one server. + * + * @property baseUrl the server checked, so a reading never describes a server the setting has left. + * @property problem why it is not available, or null when it is. + * @property httpStatus the status a [ServerProblem.SERVER_ERROR] answered with. + */ +internal data class HealthReading( + val baseUrl: String, + val status: CapabilityStatus, + val problem: ServerProblem? = null, + val httpStatus: Int? = null, +) { + companion object { + fun available(baseUrl: String) = HealthReading(baseUrl, CapabilityStatus.AVAILABLE) + + fun unreachable(baseUrl: String) = + HealthReading(baseUrl, CapabilityStatus.DEGRADED, ServerProblem.UNREACHABLE) + + /** + * Reads a non-2xx answer to `GET /models`. Any answer at all means the server is up, so + * only a refused key and a server-side failure count against it; a 404 is just a server + * that does not implement the catalog, which many compatible ones do not. + */ + fun ofHttpStatus(baseUrl: String, code: Int): HealthReading = when { + code == 401 || code == 403 -> + HealthReading(baseUrl, CapabilityStatus.DEGRADED, ServerProblem.KEY_REFUSED) + code >= 500 -> + HealthReading(baseUrl, CapabilityStatus.DEGRADED, ServerProblem.SERVER_ERROR, code) + else -> available(baseUrl) + } + } +} + +/** + * The last thing learned about the configured server, for the Agent's backend tag. + * + * Read on the UI thread, written from checks and requests on IO, so it only ever holds one + * immutable reading. + * + * @param onChange told after a reading that differs from the last, so the tag re-reads. + */ +internal class ServerHealth(private val onChange: () -> Unit) { + + @Volatile + private var last: HealthReading? = null + + /** + * @param baseUrl the server the setting names now. + * @return its reading; a server not checked yet is [CapabilityStatus.CONNECTING]. + */ + fun readingFor(baseUrl: String): HealthReading = + last?.takeIf { it.baseUrl == baseUrl } ?: HealthReading(baseUrl, CapabilityStatus.CONNECTING) + + /** Stores [reading], telling [onChange] only when it differs from the last. */ + fun record(reading: HealthReading) { + val previous = last + last = reading + if (previous != reading) onChange() + } +} diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt index a960720f..7f3177cd 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt @@ -2,17 +2,16 @@ package com.itsaky.androidide.plugins.aiagentopenai.plugin import com.itsaky.androidide.plugins.IPlugin import com.itsaky.androidide.plugins.PluginContext -import com.itsaky.androidide.plugins.PluginLifecycleListener +import com.itsaky.androidide.plugins.ai.LlmBackendRegistration import com.itsaky.androidide.plugins.ai.prompt.AssetPromptConfigSource import com.itsaky.androidide.plugins.aiagentopenai.backend.OpenAiBackend +import com.itsaky.androidide.plugins.aiagentopenai.preferences.OpenAiPreferences import com.itsaky.androidide.plugins.aiagentopenai.prompt.OpenAiSystemPrompt import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.sharedPromptConfig import com.itsaky.androidide.plugins.extensions.DocumentationExtension import com.itsaky.androidide.plugins.extensions.PluginTooltipButton import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry -import com.itsaky.androidide.plugins.services.LlmInferenceService -import com.itsaky.androidide.plugins.services.SharedServices /** * Registers the OpenAI-compatible backend with AI Core's inference router. @@ -26,15 +25,12 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { private lateinit var context: PluginContext private var backend: OpenAiBackend? = null - /** True once [backend] is registered with the router, so re-registration is idempotent. */ - @Volatile private var registered = false + /** Keeps [backend] registered with AI Core across its restarts, and reports setting changes. */ + private lateinit var registration: LlmBackendRegistration companion object { const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentopenai" - /** Provider of [LlmInferenceService]; this plugin is useless without it. */ - private const val AI_CORE_PLUGIN_ID = "com.itsaky.androidide.plugins.aicore" - private const val TOOLTIP_TAG_PLUGIN = "plugin_ai_backend_openai" /** @@ -52,6 +48,16 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { const val TOOLTIP_TAG_SETTINGS_TEST = "ai_openai_test_connection" const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_openai_get_key" + /** The settings that change what [OpenAiBackend.isAvailable] or its model name answers. */ + private val WATCHED_KEYS = setOf( + OpenAiPreferences.KEY_BASE_URL, + OpenAiPreferences.KEY_API_KEY, + OpenAiPreferences.KEY_MODEL, + ) + + /** The settings that change whether the server answers [OpenAiBackend.checkServer]. */ + private val RECHECK_KEYS = setOf(OpenAiPreferences.KEY_BASE_URL, OpenAiPreferences.KEY_API_KEY) + @Volatile private var pluginContext: PluginContext? = null @@ -68,31 +74,22 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { fun getBackend(): OpenAiBackend? = activeBackend } - /** - * Re-registers when AI Core activates. Plugins load in parallel with no ordering, so - * [activate] may run before AI Core has published its service; this closes that race instead - * of polling for it. - */ - private val aiCoreLifecycle = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) { - if (pluginId == AI_CORE_PLUGIN_ID) registerBackend() - } - - override fun onPluginDeactivated(pluginId: String) { - // The router went away and took the registration with it; allow a fresh one. - if (pluginId == AI_CORE_PLUGIN_ID) registered = false - } - - override fun onPluginUninstalled(pluginId: String) { - if (pluginId == AI_CORE_PLUGIN_ID) registered = false - } - } - override fun initialize(context: PluginContext): Boolean { return try { this.context = context // Published for the settings pane, which the hosting screen constructs directly. pluginContext = context + registration = LlmBackendRegistration( + context = context, + preferences = { OpenAiPreferences.of(context) }, + watchedKeys = WATCHED_KEYS, + // A new server or key makes the last check describe something else. + onSettingChanged = { key -> + if (key == null || key in RECHECK_KEYS) backend?.checkServer() + }, + // After registering, so the result has someone to be announced to. + onRegistered = { backend?.checkServer() }, + ) context.logger.info("OpenAiPlugin: Plugin initialized successfully") true } catch (e: Exception) { @@ -109,21 +106,17 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { releaseBackend() preloadPromptConfig() - val openAi = OpenAiBackend(context, sharedPromptConfig::configIfLoaded) + val openAi = OpenAiBackend( + context, + sharedPromptConfig::configIfLoaded, + onStatusChanged = registration::notifyBackendChanged, + ) backend = openAi activeBackend = openAi // Decrypt the key off-thread now, so a main-thread isAvailable() can't say "no key". openAi.warmKeyCache() - - // Listen first, then try: a listener added after a successful attempt would still be - // needed for a later AI Core restart, and one added before costs nothing. - context.addPluginLifecycleListener(aiCoreLifecycle) - if (!registerBackend()) { - context.logger.info( - "OpenAiPlugin: AI Core is not active yet; will register when it activates" - ) - } + registration.start(openAi) true } catch (e: Exception) { @@ -132,44 +125,6 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { } } - /** - * Registers the backend with AI Core's router, if the router is reachable. - * - * @return true when the backend is registered (now or already), false when AI Core is absent - */ - private fun registerBackend(): Boolean { - if (registered) return true - val openAi = backend ?: return false - - val service = resolveInferenceService() - if (service == null) { - context.logger.debug("OpenAiPlugin: LlmInferenceService not available yet") - return false - } - - return try { - service.registerBackend(openAi) - registered = true - context.logger.info("OpenAiPlugin: Registered '${openAi.getId()}' backend with AI Core") - true - } catch (e: Exception) { - context.logger.error("OpenAiPlugin: Could not register the OpenAI backend", e) - false - } - } - - /** - * Resolves AI Core's router, preferring the process-global registry and falling back to the - * provider-scoped lookup so a registry cleared by another plugin is not fatal. - */ - private fun resolveInferenceService(): LlmInferenceService? = try { - SharedServices.get(LlmInferenceService::class.java) - ?: context.getPluginService(AI_CORE_PLUGIN_ID, LlmInferenceService::class.java) - } catch (e: Exception) { - context.logger.warn("OpenAiPlugin: Could not resolve LlmInferenceService: ${e.message}") - null - } - /** Reads and validates the prompt config now, so building a prompt does no disk I/O. */ private fun preloadPromptConfig() { val source = AssetPromptConfigSource(context.androidContext.assets) @@ -197,15 +152,6 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { context.logger.info("OpenAiPlugin: Deactivating plugin") return try { - context.removePluginLifecycleListener(aiCoreLifecycle) - - val openAi = backend - if (openAi != null && registered) { - resolveInferenceService()?.unregisterBackend(openAi.getId()) - registered = false - context.logger.info("OpenAiPlugin: Unregistered '${openAi.getId()}' backend") - } - // A disabled plugin must not keep the decrypted key on the host heap. releaseBackend() sharedPromptConfig.clear() @@ -218,22 +164,19 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { } /** - * Cancels in-flight requests, drops the decrypted key from the heap, and clears the published - * backend. Idempotent, so a [deactivate] followed by [dispose] closes nothing twice. + * Unregisters from AI Core, cancels in-flight requests, drops the decrypted key from the heap, + * and clears the published backend. Idempotent, so [deactivate] then [dispose] is safe. */ private fun releaseBackend() { + if (::registration.isInitialized) registration.stop() backend?.close() backend = null activeBackend = null - registered = false } override fun dispose() { context.logger.info("OpenAiPlugin: Disposing plugin") - // deactivate() removes this too; a dispose without one would leave the host holding this. - runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } - releaseBackend() sharedPromptConfig.clear() pluginContext = null diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicy.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicy.kt index 1ccbaae2..592371c7 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicy.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicy.kt @@ -87,6 +87,14 @@ internal object BaseUrlPolicy { /** Matches an IPv4 address, so its octets can be tested against the private ranges. */ private val IPV4 = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$""") + /** + * The `host[:port]` of [url], read with the same pattern [normalize] accepts, so it never throws. + * + * @return the authority, or null for a URL not of the accepted shape + */ + fun authorityOf(url: String): String? = + URL_SHAPE.matchEntire(url.trim())?.groupValues?.get(2)?.takeIf { it.isNotBlank() } + /** * Normalizes [input] and applies the cleartext rule. * diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ServerPreset.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ServerPreset.kt index f45ca56e..d276b46c 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ServerPreset.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/ServerPreset.kt @@ -41,6 +41,13 @@ internal object ServerPresets { ServerPreset(R.string.preset_custom, null), ) + /** + * The preset [url] belongs to, for naming the server it points at. + * + * @return the preset, or null for a URL none offers + */ + fun presetFor(url: String?): ServerPreset? = ALL.getOrNull(indexOf(url))?.takeIf { it.url != null } + /** * The preset whose URL matches [url], for restoring the picker's position. * diff --git a/plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml b/plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml index 9188864c..a4fa1518 100644 --- a/plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml +++ b/plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml @@ -3,6 +3,8 @@ OpenAI + + %1$s (%2$s) OpenAI diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackendTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackendTest.kt index e06342d6..86ea901c 100644 --- a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackendTest.kt +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackendTest.kt @@ -17,7 +17,7 @@ import org.junit.Test */ class OpenAiBackendTest { - private val backend = OpenAiBackend(mockk(relaxed = true)) { null } + private val backend = OpenAiBackend(mockk(relaxed = true), promptConfig = { null }) @Test fun givenConfigNotYetLoaded_whenAskedForItsPrompt_thenItReturnsNullInsteadOfBlocking() { diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocolTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocolTest.kt index c3b65a57..84f76bdb 100644 --- a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocolTest.kt +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocolTest.kt @@ -67,6 +67,15 @@ class OpenAiToolProtocolTest { val parameters = declarations.getJSONObject(0).getJSONObject("function") .getJSONObject("parameters") assertEquals("object", parameters.getString("type")) + assertEquals(0, parameters.getJSONObject("properties").length()) + } + + @Test + fun givenAnObjectSchemaWithoutProperties_whenDeclared_thenItGetsAnEmptyOne() { + // LM Studio refuses the whole request when any tool's parameters lacks `properties`. + val parameters = OpenAiToolProtocol.parametersJson(mapOf("type" to "object")) + + assertEquals(0, parameters.getJSONObject("properties").length()) } @Test diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/ServerHealthTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/ServerHealthTest.kt new file mode 100644 index 00000000..ae156e4c --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/ServerHealthTest.kt @@ -0,0 +1,66 @@ +package com.itsaky.androidide.plugins.aiagentopenai.backend + +import com.itsaky.androidide.plugins.services.CapabilityStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Tests what a server check reports, and when the Agent's tag is told about it. */ +class ServerHealthTest { + + private companion object { + const val LM_STUDIO = "http://localhost:1234/v1" + const val OLLAMA = "http://localhost:11434/v1" + } + + @Test + fun givenNoCheckYet_whenRead_thenItIsConnecting() { + val health = ServerHealth {} + + assertEquals(CapabilityStatus.CONNECTING, health.readingFor(LM_STUDIO).status) + } + + @Test + fun givenAReadingForAnotherServer_whenRead_thenItDoesNotApply() { + val health = ServerHealth {} + health.record(HealthReading.unreachable(OLLAMA)) + + assertEquals(CapabilityStatus.CONNECTING, health.readingFor(LM_STUDIO).status) + } + + @Test + fun givenTheSameReadingTwice_whenRecorded_thenTheTagIsToldOnce() { + var told = 0 + val health = ServerHealth { told++ } + + health.record(HealthReading.unreachable(LM_STUDIO)) + health.record(HealthReading.unreachable(LM_STUDIO)) + health.record(HealthReading.available(LM_STUDIO)) + + assertEquals(2, told) + } + + @Test + fun givenAServerWithoutACatalog_whenItAnswers404_thenItIsAvailable() { + val reading = HealthReading.ofHttpStatus(LM_STUDIO, 404) + + assertEquals(CapabilityStatus.AVAILABLE, reading.status) + assertNull(reading.problem) + } + + @Test + fun givenARefusedKey_whenItAnswers401_thenItIsDegradedForTheKey() { + val reading = HealthReading.ofHttpStatus(LM_STUDIO, 401) + + assertEquals(CapabilityStatus.DEGRADED, reading.status) + assertEquals(ServerProblem.KEY_REFUSED, reading.problem) + } + + @Test + fun givenAFailingServer_whenItAnswers503_thenItIsDegradedWithTheStatus() { + val reading = HealthReading.ofHttpStatus(LM_STUDIO, 503) + + assertEquals(ServerProblem.SERVER_ERROR, reading.problem) + assertEquals(503, reading.httpStatus) + } +} diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicyTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicyTest.kt index 27dad5c6..adef055f 100644 --- a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicyTest.kt +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicyTest.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.plugins.aiagentopenai.settings import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -250,4 +251,17 @@ class BaseUrlPolicyTest { assertFalse(BaseUrlPolicy.isOpenAiApi("https://openrouter.ai/api/v1")) assertFalse(BaseUrlPolicy.isOpenAiApi(null)) } + + @Test + fun givenAnAcceptedUrl_whenReadingItsAuthority_thenHostAndPortAreReturned() { + assertEquals("192.168.1.20:1234", BaseUrlPolicy.authorityOf("http://192.168.1.20:1234/v1")) + assertEquals("example.com", BaseUrlPolicy.authorityOf("https://example.com")) + } + + @Test + fun givenAMalformedUrl_whenReadingItsAuthority_thenNoneIsReturned() { + assertNull(BaseUrlPolicy.authorityOf("localhost:1234")) + assertNull(BaseUrlPolicy.authorityOf("https:///v1")) + assertNull(BaseUrlPolicy.authorityOf("http://exa mple.com")) + } } diff --git a/plugins/AI-Core/src/main/AndroidManifest.xml b/plugins/AI-Core/src/main/AndroidManifest.xml index f3b53d8c..912b9a9a 100644 --- a/plugins/AI-Core/src/main/AndroidManifest.xml +++ b/plugins/AI-Core/src/main/AndroidManifest.xml @@ -39,6 +39,10 @@ android:name="plugin.author" android:value="App Dev for All" /> + diff --git a/plugins/AI-Core/src/main/assets/docs/index.html b/plugins/AI-Core/src/main/assets/docs/index.html index 54b96536..99d0d59e 100644 --- a/plugins/AI-Core/src/main/assets/docs/index.html +++ b/plugins/AI-Core/src/main/assets/docs/index.html @@ -242,6 +242,25 @@

Writing a message

warning itself goes away on its own once the backend is configured. +

What the agent is connected to

+

The row of tags under the message field shows, before you send anything, + what will answer and what the agent can reach:

+
    +
  • The backend, with the model it will use when the backend reports + one — Gemini API · gemini-2.5-flash, for example.
  • +
  • Web, or Offline while the device has no internet + connection. Network backends and MCP servers need it; a backend that runs + on the device does not.
  • +
  • One tag per tool source: each MCP server you configured, and each + plugin that adds tools to the agent.
  • +
+

The tags update on their own as things connect, disconnect or change. A + red tag is set up but not working right now — a server that cannot be + reached, a backend with no API key — and stays in the row so you can see what + is broken; it clears by itself once the problem is fixed. Tap a tag for its + details: where it comes from, how many tools it offers and, when red, why. + Long-press one for help. With many tags the row scrolls sideways.

+

Reading the conversation

  • Retry appears on a step that failed — including a tool call you diff --git a/plugins/AI-Core/src/main/assets/prompts/layout.yml b/plugins/AI-Core/src/main/assets/prompts/layout.yml index 7babab8a..0616c515 100644 --- a/plugins/AI-Core/src/main/assets/prompts/layout.yml +++ b/plugins/AI-Core/src/main/assets/prompts/layout.yml @@ -2,7 +2,7 @@ # A line holding only a section tag (#, ^ or /) vanishes, so tags can sit on their own lines. layout: - system_prompt: | + system_prompt: |- {{IDENTITY}} {{#RULES}} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/backends/BackendRegistry.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/backends/BackendRegistry.kt index 8d07bce9..76918875 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/backends/BackendRegistry.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/backends/BackendRegistry.kt @@ -156,6 +156,12 @@ object BackendRegistry { */ fun select(backendId: String) { prefs()?.edit()?.putString(AiBackend.PREFERENCE_KEY, backendId)?.apply() + // A new selection changes which backend the chat's tag row names, so its listeners hear. + try { + service()?.notifyBackendChanged(backendId) + } catch (e: Throwable) { + logError("could not announce the selection of '$backendId'", e) + } } /** diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityMonitor.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityMonitor.kt new file mode 100644 index 00000000..76b9dc9e --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityMonitor.kt @@ -0,0 +1,187 @@ +package com.itsaky.androidide.plugins.aicore.capabilities + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.util.Log +import com.itsaky.androidide.plugins.aicore.backends.BackendRegistry +import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX +import com.itsaky.androidide.plugins.services.LlmInferenceService +import com.itsaky.androidide.plugins.services.SharedServices +import com.itsaky.androidide.plugins.services.ToolSourceRegistry +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private const val TAG = "$LOG_PREFIX.CapabilityMonitor" + +/** + * What the agent is connected to, as a live row of [CapabilityTag]s. + * + * Nothing here polls: the router, the tool registry and the connectivity service each say when + * something changed, and every signal only schedules a re-read. The re-read runs on IO, because + * a backend's `isAvailable` may touch the Keystore and a source is another plugin's code. + * + * @param context any Context; only its application Context is kept. + */ +class CapabilityMonitor(context: Context) { + + companion object { + /** Counts AI Core's publishes of its services; [changes] re-attaches on each. */ + private val published = MutableStateFlow(0) + + /** + * Called by AI Core once it has registered its services, so a row already on screen moves + * its listeners to the new instances. `SharedServices` announces nothing itself. + */ + fun onServicesPublished() { + published.update { it + 1 } + } + } + + private val appContext = context.applicationContext + + /** Last answer from the connectivity service; see [networkCallback]. */ + private val online = AtomicBoolean(true) + + /** + * The tags, re-read on subscription and after every change signal. Bursts — an MCP refresh + * reporting server after server — collapse into one re-read, and an unchanged row is dropped. + * + * @return a cold flow; the listeners live exactly as long as a collector does. + */ + fun tags(): Flow> = + changes() + .conflate() + .map { read() } + .flowOn(Dispatchers.IO) + .distinctUntilChanged() + + /** Reads every tag now. */ + private fun read(): List { + val service = llmService() + val backend = CapabilityReaders.backend(BackendRegistry.selected(), service) + val sources = try { + toolRegistry()?.toolSources.orEmpty() + } catch (e: Throwable) { + Log.w(TAG, "Could not list the tool sources", e) + emptyList() + } + return capabilityRow(backend, CapabilityTag.Web(online.get()), CapabilityReaders.tools(sources)) + } + + /** + * One signal per change anywhere the row reads from, starting with one for the first read. + * The listeners re-attach on every [onServicesPublished], so a router replaced by an AI Core + * restart, or one absent when the row started, is still heard. + */ + private fun changes(): Flow = callbackFlow { + val backendListener = LlmInferenceService.BackendChangeListener { trySend(Unit) } + val sourceListener = ToolSourceRegistry.ToolSourceListener { trySend(Unit) } + val lock = Any() + var service: LlmInferenceService? = null + var registry: ToolSourceRegistry? = null + + fun detach() = synchronized(lock) { + service?.let { + guard("remove the backend listener") { it.removeBackendChangeListener(backendListener) } + } + registry?.let { + guard("remove the tool-source listener") { it.removeToolSourceListener(sourceListener) } + } + service = null + registry = null + } + + fun attach() = synchronized(lock) { + service = llmService()?.also { + guard("add the backend listener") { it.addBackendChangeListener(backendListener) } + } + registry = toolRegistry()?.also { + guard("add the tool-source listener") { it.addToolSourceListener(sourceListener) } + } + } + + val network = networkCallback { trySend(Unit) } + // A StateFlow replays its value, so this attaches once at once and again on each publish. + val republished = launch { + published.collect { + detach() + attach() + trySend(Unit) + } + } + awaitClose { + republished.cancel() + detach() + network?.let { callback -> + guard("unregister the network callback") { + connectivity()?.unregisterNetworkCallback(callback) + } + } + } + } + + /** + * Tracks whether the default network reaches the internet, updating [online] and calling + * [onChange] whenever that answer flips. + * + * @return the registered callback, or null when the connectivity service would not take one; + * the tag then keeps its last reading rather than claiming the device is offline. + */ + private fun networkCallback(onChange: () -> Unit): ConnectivityManager.NetworkCallback? { + val manager = connectivity() ?: return null + guard("read the current network") { + online.set(hasInternet(manager.getNetworkCapabilities(manager.activeNetwork))) + } + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { + val now = hasInternet(capabilities) + if (online.getAndSet(now) != now) onChange() + } + + override fun onLost(network: Network) { + if (online.getAndSet(false)) onChange() + } + } + return try { + manager.registerDefaultNetworkCallback(callback) + callback + } catch (e: Exception) { + Log.w(TAG, "Could not watch the network; the web tag will not update", e) + null + } + } + + private fun hasInternet(capabilities: NetworkCapabilities?): Boolean = + capabilities != null && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + + private fun connectivity(): ConnectivityManager? = + appContext.getSystemService(ConnectivityManager::class.java) + + private fun llmService(): LlmInferenceService? = + guard("resolve the inference service") { SharedServices.get(LlmInferenceService::class.java) } + + /** Throwable-guarded like the registry's own registration, for an IDE without the contract. */ + private fun toolRegistry(): ToolSourceRegistry? = + guard("resolve the tool registry") { SharedServices.get(ToolSourceRegistry::class.java) } + + private inline fun guard(what: String, block: () -> T): T? = try { + block() + } catch (e: Throwable) { + Log.w(TAG, "Could not $what", e) + null + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityReaders.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityReaders.kt new file mode 100644 index 00000000..6f387cff --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityReaders.kt @@ -0,0 +1,153 @@ +package com.itsaky.androidide.plugins.aicore.capabilities + +import android.util.Log +import com.itsaky.androidide.plugins.aicore.backends.SelectedBackend +import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX +import com.itsaky.androidide.plugins.services.CapabilityStatus +import com.itsaky.androidide.plugins.services.LlmInferenceService +import com.itsaky.androidide.plugins.services.ToolSourceRegistry + +private const val TAG = "$LOG_PREFIX.CapabilityReaders" + +/** + * Reads the host contracts into [CapabilityTag]s. + * + * Every call here reaches into another plugin's code, so each is guarded on its own: a source or + * backend that throws costs its own tag the detail it failed to give, never the whole row. + */ +object CapabilityReaders { + + /** + * The tag for the backend the user selected. + * + * @param selected the selection, resolved as the settings screen resolves it. + * @param service the router, to reach the backend object behind the selection. + * @return the backend tag; a backend that cannot be asked reads as not configured. + */ + fun backend(selected: SelectedBackend, service: LlmInferenceService?): CapabilityTag.Backend = + when (selected) { + SelectedBackend.None -> CapabilityTag.Backend(null, null, BackendState.NONE_INSTALLED) + SelectedBackend.Missing -> CapabilityTag.Backend(null, null, BackendState.NOT_INSTALLED) + is SelectedBackend.Installed -> { + val option = selected.option + val backend = guard("backend '${option.id}'") { service?.getBackend(option.id) } + val available = backend != null && + guard("backend '${option.id}' availability") { backend.isAvailable } == true + // Asked only of a configured backend; one that does not report status is taken at + // its isAvailable word. + val reporting = backend as? LlmInferenceService.StatusReportingBackend + val health = if (available && reporting != null) { + guard("backend '${option.id}' status") { reporting.status } + ?.let(::status) ?: CapabilityStatus.AVAILABLE + } else { + CapabilityStatus.AVAILABLE + } + val message = if (health != CapabilityStatus.AVAILABLE) { + message(guard("backend '${option.id}' message") { reporting?.statusMessage }) + } else { + null + } + CapabilityTag.Backend( + name = option.displayName, + modelName = backend?.let { modelName(option.id, it) }, + state = if (available) BackendState.READY else BackendState.NOT_CONFIGURED, + health = health, + statusMessage = message, + ) + } + } + + /** + * One tag per group of every source that reports groups, and one per source that does not. + * + * @param sources the registered sources, in registration order. + * @return the tags, in that order; a source that cannot even name itself is left out. + */ + fun tools(sources: List): List = + sources.flatMap(::toolsOf) + + /** + * Normalizes a provider's status for display. Null from a misbehaving provider, or a constant + * added to the contract after this build, reads as degraded, which the contract calls safest. + * + * @param status the provider's status. + * @return the status to show. + */ + fun status(status: CapabilityStatus?): CapabilityStatus = when (status) { + CapabilityStatus.AVAILABLE -> CapabilityStatus.AVAILABLE + CapabilityStatus.CONNECTING -> CapabilityStatus.CONNECTING + else -> CapabilityStatus.DEGRADED + } + + private fun toolsOf(source: ToolSourceRegistry.ToolSource): List { + val providerId = guard("a tool source's providerId") { source.providerId } + ?.trim()?.takeIf { it.isNotEmpty() } ?: return emptyList() + val sourceName = guard("source '$providerId' displayName") { source.displayName } + ?.takeIf { it.isNotBlank() } ?: providerId + + val groups = (source as? ToolSourceRegistry.GroupedToolSource) + ?.let { guard("source '$providerId' groups") { it.toolGroups } }.orEmpty() + if (groups.isNotEmpty()) return groups.mapNotNull { group -> groupTag(providerId, sourceName, group) } + + val tag = CapabilityTag.Tools( + providerId = providerId, + groupId = null, + name = sourceName, + sourceName = sourceName, + toolCount = guard("source '$providerId' tools") { source.listTools() }?.size ?: 0, + status = sourceStatus(providerId, source), + statusMessage = (source as? ToolSourceRegistry.StatusReportingToolSource) + ?.let { message(guard("source '$providerId' message") { it.statusMessage }) }, + ) + // Offering nothing and not broken — MCP with no server switched on — is not connected to + // anything; a broken one with no tools is exactly what must stay visible. + val idle = tag.toolCount == 0 && tag.status == CapabilityStatus.AVAILABLE + return if (idle) emptyList() else listOf(tag) + } + + /** A source that does not report status is taken as available. */ + private fun sourceStatus(providerId: String, source: ToolSourceRegistry.ToolSource): CapabilityStatus = + (source as? ToolSourceRegistry.StatusReportingToolSource) + ?.let { status(guard("source '$providerId' status") { it.status }) } + ?: CapabilityStatus.AVAILABLE + + private fun groupTag( + providerId: String, + sourceName: String, + group: ToolSourceRegistry.ToolGroup?, + ): CapabilityTag.Tools? { + group ?: return null + val groupId = guard("a group of '$providerId'") { group.id } + ?.takeIf { it.isNotBlank() } ?: return null + val label = "group '$groupId' of '$providerId'" + return CapabilityTag.Tools( + providerId = providerId, + groupId = groupId, + name = guard("$label displayName") { group.displayName }?.takeIf { it.isNotBlank() } ?: groupId, + sourceName = sourceName, + toolCount = guard("$label tools") { group.toolNames }?.size ?: 0, + status = status(guard("$label status") { group.status }), + statusMessage = message(guard("$label message") { group.statusMessage }), + ) + } + + private fun modelName(id: String, backend: LlmInferenceService.LlmBackend): String? = + (backend as? LlmInferenceService.ActiveModelReportingBackend) + ?.let { guard("backend '$id' model name") { it.activeModelName } } + ?.trim()?.takeIf { it.isNotEmpty() } + + private fun message(text: String?): String? = text?.trim()?.takeIf { it.isNotEmpty() } + + /** + * Runs one call into another plugin, logging and swallowing whatever it throws. + * + * @param what names the call for the log. + * @return the call's answer, or null when it threw. + */ + private inline fun guard(what: String, block: () -> T): T? = try { + block() + } catch (e: Throwable) { + Log.w(TAG, "Reading $what failed", e) + null + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityTag.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityTag.kt new file mode 100644 index 00000000..ea582f2f --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityTag.kt @@ -0,0 +1,109 @@ +package com.itsaky.androidide.plugins.aicore.capabilities + +import com.itsaky.androidide.plugins.services.CapabilityStatus + +/** Why the backend tag reads the way it does; each case is a different fix for the user. */ +enum class BackendState { + /** The selected backend is installed and reports itself configured. */ + READY, + + /** The selected backend is installed but not set up yet — no key, no model. */ + NOT_CONFIGURED, + + /** A selection is stored, but the plugin that provided it is gone. */ + NOT_INSTALLED, + + /** No backend plugin is installed at all. */ + NONE_INSTALLED, +} + +/** + * One tag in the row under the chat input: something the agent is connected to. + * + * Carries data only, never display text, so the row is built and tested without resources; the + * one exception is [Tools.statusMessage], which the provider wrote for the user in its own words. + */ +sealed interface CapabilityTag { + + /** Stable identity, so the row can tell an updated tag from a new one. */ + val key: String + + val status: CapabilityStatus + + /** + * The backend that will answer. + * + * @property name the backend's own label, or null when none is installed to name. + * @property modelName the model it will answer with, or null when it does not say. + * @property health whether a [BackendState.READY] backend's server answers, as it reports it. + * @property statusMessage the backend's own sentence about a [health] other than available. + */ + data class Backend( + val name: String?, + val modelName: String?, + val state: BackendState, + val health: CapabilityStatus = CapabilityStatus.AVAILABLE, + val statusMessage: String? = null, + ) : CapabilityTag { + override val key: String get() = KEY + override val status: CapabilityStatus + get() = if (state == BackendState.READY) health else CapabilityStatus.DEGRADED + + companion object { + const val KEY = "backend" + } + } + + /** + * Whether the device can reach the internet, which every network backend and MCP server needs. + * + * @property online true when the default network is validated for internet access. + */ + data class Web(val online: Boolean) : CapabilityTag { + override val key: String get() = KEY + override val status: CapabilityStatus + get() = if (online) CapabilityStatus.AVAILABLE else CapabilityStatus.DEGRADED + + companion object { + const val KEY = "web" + } + } + + /** + * One source of plugin-contributed tools, or one group of a source that reports groups — one + * MCP server, say. + * + * @property providerId the contributing source's provider id. + * @property groupId the group's id within that source, or null when the source is one unit. + * @property name the group's name, or the source's when it reports no groups. + * @property sourceName the contributing source's display name. + * @property toolCount how many tools this tag stands for; zero is legitimate for a broken one. + * @property statusMessage the provider's own sentence about a non-available status, if any. + */ + data class Tools( + val providerId: String, + val groupId: String?, + val name: String, + val sourceName: String, + val toolCount: Int, + override val status: CapabilityStatus, + val statusMessage: String?, + ) : CapabilityTag { + override val key: String get() = "tools:$providerId:${groupId.orEmpty()}" + } +} + +/** + * The row's order: backend first since it answers every prompt, then web access, then tool + * sources in registration order. + * + * @param backend the backend tag. + * @param web the web access tag. + * @param tools the tool-source tags, already in presentation order. + * @return the tags as the row shows them. + */ +fun capabilityRow( + backend: CapabilityTag.Backend, + web: CapabilityTag.Web, + tools: List, +): List = listOf(backend, web) + tools diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/ToolSourceScreens.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/ToolSourceScreens.kt new file mode 100644 index 00000000..300df7f1 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/ToolSourceScreens.kt @@ -0,0 +1,42 @@ +package com.itsaky.androidide.plugins.aicore.capabilities + +import androidx.annotation.StringRes +import com.itsaky.androidide.plugins.aicore.R + +/** + * A screen another plugin owns, which the host mounts with that plugin's own class loader. + * + * @property pluginId the owning plugin's id. + * @property fragmentClassName the screen's Fragment, as that plugin's settings entry names it. + * @property titleRes the screen's title, from this plugin's resources. + */ +data class PluginScreen( + val pluginId: String, + val fragmentClassName: String, + @StringRes val titleRes: Int, +) + +/** + * The settings screen of each tool source that has one. + * + * Kept here because `ToolSource` carries no settings hook; each entry must track the class its + * plugin's `getSettingsEntries()` names, or the host shows a blank screen. + */ +object ToolSourceScreens { + + private const val MCP_PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentmcp" + + private val screens = mapOf( + MCP_PLUGIN_ID to PluginScreen( + pluginId = MCP_PLUGIN_ID, + fragmentClassName = "com.itsaky.androidide.plugins.aiagentmcp.settings.McpSettingsFragment", + titleRes = R.string.pref_mcp_title, + ), + ) + + /** + * @param providerId the tool source's provider id, which is its plugin's id. + * @return the source's settings screen, or null when it has none known here. + */ + fun settingsFor(providerId: String): PluginScreen? = screens[providerId] +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/CapabilityTagRowController.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/CapabilityTagRowController.kt new file mode 100644 index 00000000..30586eeb --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/CapabilityTagRowController.kt @@ -0,0 +1,234 @@ +package com.itsaky.androidide.plugins.aicore.fragments + +import android.content.Context +import android.content.res.ColorStateList +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.chip.Chip +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.itsaky.androidide.plugins.aicore.R +import com.itsaky.androidide.plugins.aicore.capabilities.BackendState +import com.itsaky.androidide.plugins.aicore.capabilities.CapabilityMonitor +import com.itsaky.androidide.plugins.aicore.capabilities.CapabilityTag +import com.itsaky.androidide.plugins.aicore.capabilities.PluginScreen +import com.itsaky.androidide.plugins.aicore.capabilities.ToolSourceScreens +import com.itsaky.androidide.plugins.aicore.databinding.FragmentChatBinding +import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin +import com.itsaky.androidide.plugins.services.CapabilityStatus +import kotlinx.coroutines.launch + +/** + * The row of tags under the chat input naming what the agent is connected to. + * + * Read-only: a tap explains a tag and a long press shows its help; nothing here changes a + * setting, bar the details offering the way into the settings of a backend that is not set up or + * of a tool source that has a settings screen. + * + * @param binding the chat's views; the row lives in the input card. + * @param monitor where the tags come from. + * @param showTooltip shows this plugin's tooltip for a tag on an anchor, false when unavailable. + * @param dialogContext the Activity-backed Context details dialogs need, or null once detached. + * @param onOpenSettings opens the Agent settings screen. + * @param onOpenPluginScreen opens a settings screen another plugin owns. + */ +class CapabilityTagRowController( + private val binding: FragmentChatBinding, + private val monitor: CapabilityMonitor, + private val showTooltip: (View, String) -> Boolean, + private val dialogContext: () -> Context?, + private val onOpenSettings: () -> Unit, + private val onOpenPluginScreen: (PluginScreen) -> Unit, +) { + + /** The details dialog on screen, so a new tap replaces it and [detach] can close it. */ + private var dialog: AlertDialog? = null + + /** + * Collects tags while [owner] is started. The listeners behind the flow are dropped when the + * fragment stops, so a chat nobody is looking at hears nothing. + * + * @param owner the fragment's view lifecycle. + */ + fun attach(owner: LifecycleOwner) { + owner.lifecycleScope.launch { + owner.repeatOnLifecycle(Lifecycle.State.STARTED) { + monitor.tags().collect(::render) + } + } + } + + /** Closes the details dialog; the view it describes is going away. */ + fun detach() { + dialog?.dismiss() + dialog = null + } + + /** Rebuilds the row. A handful of chips, so replacing them beats diffing them. */ + private fun render(tags: List) { + val group = binding.capabilityTagGroup + group.removeAllViews() + tags.forEachIndexed { index, tag -> group.addView(chipFor(tag), layoutParams(group, index)) } + } + + private fun layoutParams(group: ViewGroup, index: Int) = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + if (index > 0) marginStart = group.resources.getDimensionPixelSize(R.dimen.capability_tag_spacing) + } + + /** + * Builds one tag. Styled in code rather than by a Chip style: `app:` attributes and theme + * attributes both resolve against the host here, not against this plugin. + */ + private fun chipFor(tag: CapabilityTag): Chip { + // The group's Context: it carries this plugin's resources and the IDE's day/night mode. + val context = binding.capabilityTagGroup.context + val res = context.resources + val colors = colorsFor(tag.status) + val label = label(context, tag) + return Chip(context).apply { + text = label + isCheckable = false + setEnsureMinTouchTargetSize(false) + chipMinHeight = res.getDimension(R.dimen.capability_tag_min_height) + setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.capability_tag_text_size)) + chipStartPadding = res.getDimension(R.dimen.capability_tag_padding_horizontal) + chipEndPadding = res.getDimension(R.dimen.capability_tag_padding_horizontal) + chipBackgroundColor = ColorStateList.valueOf(context.getColor(colors.background)) + chipStrokeColor = ColorStateList.valueOf(context.getColor(colors.stroke)) + chipStrokeWidth = res.getDimension(R.dimen.capability_tag_stroke_width) + setTextColor(context.getColor(colors.text)) + setChipIconResource(R.drawable.ic_status_dot) + chipIconSize = res.getDimension(R.dimen.capability_tag_icon_size) + chipIconTint = ColorStateList.valueOf(context.getColor(colors.dot)) + isChipIconVisible = true + contentDescription = context.getString( + R.string.desc_capability_tag, label, context.getString(statusLabel(tag.status)) + ) + setOnClickListener { showDetails(tag) } + setOnLongClickListener { anchor -> showTooltip(anchor, tooltipTagFor(tag)) } + } + } + + /** The tag's own text; everything longer lives in its details. */ + private fun label(context: Context, tag: CapabilityTag): String = when (tag) { + is CapabilityTag.Backend -> when (tag.state) { + BackendState.NONE_INSTALLED -> context.getString(R.string.backend_none_installed_short) + BackendState.NOT_INSTALLED -> context.getString(R.string.backend_selected_missing_short) + else -> tag.name.orEmpty().let { name -> + tag.modelName?.let { context.getString(R.string.capability_backend_model, name, it) } ?: name + } + } + is CapabilityTag.Web -> context.getString( + if (tag.online) R.string.capability_web else R.string.capability_web_offline + ) + is CapabilityTag.Tools -> tag.name + } + + /** Explains one tag: where it comes from, how many tools it stands for and, if broken, why. */ + private fun showDetails(tag: CapabilityTag) { + val context = dialogContext() ?: return + val status = context.getString( + R.string.capability_detail_status, context.getString(statusLabel(tag.status)) + ) + val lines = mutableListOf() + var openSettings: (() -> Unit)? = null + when (tag) { + is CapabilityTag.Backend -> { + lines += context.getString(R.string.capability_detail_kind_backend) + tag.name?.let { lines += context.getString(R.string.capability_detail_source, it) } + if (tag.state == BackendState.READY || tag.state == BackendState.NOT_CONFIGURED) { + lines += tag.modelName?.let { context.getString(R.string.capability_detail_model, it) } + ?: context.getString(R.string.capability_detail_model_unknown) + } + lines += status + backendReason(tag.state)?.let { reason -> + lines += context.getString(R.string.capability_detail_reason, context.getString(reason)) + } + tag.statusMessage?.let { lines += context.getString(R.string.capability_detail_reason, it) } + if (tag.status != CapabilityStatus.AVAILABLE) openSettings = onOpenSettings + } + is CapabilityTag.Web -> { + lines += context.getString(R.string.capability_detail_kind_web) + lines += status + lines += context.getString( + if (tag.online) R.string.capability_web_online_detail else R.string.capability_web_offline_detail + ) + } + is CapabilityTag.Tools -> { + lines += context.getString(R.string.capability_detail_source, tag.sourceName) + lines += context.resources.getQuantityString( + R.plurals.capability_detail_tools, tag.toolCount, tag.toolCount + ) + lines += status + if (tag.status != CapabilityStatus.AVAILABLE) { + tag.statusMessage?.let { lines += context.getString(R.string.capability_detail_reason, it) } + } + ToolSourceScreens.settingsFor(tag.providerId)?.let { screen -> + openSettings = { onOpenPluginScreen(screen) } + } + } + } + + val builder = MaterialAlertDialogBuilder(context) + .setTitle(label(binding.capabilityTagGroup.context, tag)) + .setMessage(lines.joinToString("\n")) + .setPositiveButton(android.R.string.ok, null) + openSettings?.let { open -> + builder.setNeutralButton(R.string.capability_detail_open_settings) { _, _ -> open() } + } + dialog?.dismiss() + dialog = builder.show() + } + + private fun backendReason(state: BackendState): Int? = when (state) { + BackendState.READY -> null + BackendState.NOT_CONFIGURED -> R.string.capability_backend_not_configured + BackendState.NOT_INSTALLED -> R.string.capability_backend_not_installed + BackendState.NONE_INSTALLED -> R.string.capability_backend_none_installed + } + + private fun statusLabel(status: CapabilityStatus): Int = when (status) { + CapabilityStatus.AVAILABLE -> R.string.capability_status_available + CapabilityStatus.CONNECTING -> R.string.capability_status_connecting + CapabilityStatus.DEGRADED -> R.string.capability_status_degraded + } + + private fun tooltipTagFor(tag: CapabilityTag): String = when (tag) { + is CapabilityTag.Backend -> AiCorePlugin.TOOLTIP_TAG_CAPABILITY_BACKEND + is CapabilityTag.Web -> AiCorePlugin.TOOLTIP_TAG_CAPABILITY_WEB + is CapabilityTag.Tools -> AiCorePlugin.TOOLTIP_TAG_CAPABILITY_TOOLS + } + + /** Colour resources for one status; degraded is the one that has to stand out. */ + private class TagColors(val background: Int, val stroke: Int, val text: Int, val dot: Int) + + private fun colorsFor(status: CapabilityStatus): TagColors = when (status) { + CapabilityStatus.AVAILABLE -> TagColors( + background = R.color.plugin_surface_variant, + stroke = R.color.plugin_outline_variant, + text = R.color.plugin_on_surface_variant, + dot = R.color.plugin_success, + ) + CapabilityStatus.CONNECTING -> TagColors( + background = R.color.plugin_surface_variant, + stroke = R.color.plugin_outline_variant, + text = R.color.plugin_text_muted, + dot = R.color.plugin_outline, + ) + CapabilityStatus.DEGRADED -> TagColors( + background = R.color.plugin_error_container, + stroke = R.color.plugin_error, + text = R.color.plugin_on_error_container, + dot = R.color.plugin_error, + ) + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt index a85ac546..4abea704 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt @@ -30,6 +30,8 @@ import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.aicore.BuildConfig import com.itsaky.androidide.plugins.aicore.R import com.itsaky.androidide.plugins.aicore.adapters.ChatAdapter +import com.itsaky.androidide.plugins.aicore.capabilities.CapabilityMonitor +import com.itsaky.androidide.plugins.aicore.capabilities.PluginScreen import com.itsaky.androidide.plugins.aicore.databinding.FragmentChatBinding import com.itsaky.androidide.plugins.aicore.logging.AgentTrace import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX @@ -96,6 +98,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { private var composer: ComposerAutoHideController? = null private var sidebar: ChatSidebarController? = null + private var capabilityTags: CapabilityTagRowController? = null /** * Closes the sidebar on Back rather than letting the press reach the host, which would shut the @@ -232,6 +235,8 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { composer = null sidebar?.detach() sidebar = null + capabilityTags?.detach() + capabilityTags = null _binding = null } @@ -303,7 +308,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { setupCutoutPadding() setupComposer(savedInstanceState) setupStatusBar() - setupBackendIndicator() + setupCapabilityTags() observeViewModel() AgentTrace.stage( @@ -400,9 +405,6 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { syncStorageToCurrentProject() // On becoming visible, so the check runs after every plugin has loaded. viewModel.checkBackendAvailability() - // Re-resolve the selected backend here: the settings screen is a separate activity that - // fully covers chat, so returning from it always delivers onResume. - viewModel.refreshBackendLabel() } /** @@ -620,7 +622,6 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { wireTooltip(binding.btnAddContext, AiCorePlugin.TOOLTIP_TAG_CONTEXT_FILES) wireTooltip(binding.inputBarCard, AiCorePlugin.TOOLTIP_TAG_CHAT_INPUT) wireTooltip(binding.sendButton, AiCorePlugin.TOOLTIP_TAG_CHAT_SEND) - wireTooltip(binding.backendStatusText, AiCorePlugin.TOOLTIP_TAG_SETTINGS_BACKEND) wireTooltip(binding.btnCancelEdit, AiCorePlugin.TOOLTIP_TAG_MESSAGE_EDIT) } @@ -776,14 +777,21 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { binding.agentStatusContainer.isVisible = false } - private fun setupBackendIndicator() { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.activeBackendLabel.collect { label -> - _binding?.backendStatusText?.text = label - } - } - } + /** + * Stands up the tag row under the input, which took the backend caption's place: the backend + * tag names what the caption did, and updates live instead of on resume. + */ + private fun setupCapabilityTags() { + val controller = CapabilityTagRowController( + binding = binding, + monitor = CapabilityMonitor(requireContext()), + showTooltip = ::showTooltip, + dialogContext = { if (isAdded) themedDialogContext() else null }, + onOpenSettings = ::openSettingsFragment, + onOpenPluginScreen = ::openPluginSettings, + ) + controller.attach(viewLifecycleOwner) + capabilityTags = controller } private fun observeViewModel() { @@ -1063,6 +1071,23 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host { } } + /** + * Opens a settings screen another plugin owns, such as MCP's servers list. The host loads the + * Fragment with that plugin's class loader, so its own resources and state apply. + * @param screen the screen to open. + */ + private fun openPluginSettings(screen: PluginScreen) { + val title = getString(screen.titleRes) + val opened = PluginFragmentHelper.getServiceRegistry(AiCorePlugin.PLUGIN_ID) + ?.get(IdeUIService::class.java) + ?.openPluginScreen(screen.pluginId, screen.fragmentClassName, title) ?: false + if (!opened) { + AiCorePlugin.getContext()?.logger + ?.warn("ChatFragment: could not open ${screen.fragmentClassName} of ${screen.pluginId}") + showInfoSnackbar(getString(R.string.msg_plugin_settings_unavailable, title)) + } + } + /** * Surfaces an [AgentState.Error] as a Snackbar with a shortcut into settings. Snackbar, never * Toast: a Toast built from the plugin's Context crashes the IDE with a SecurityException, diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt index e62cf109..ca836293 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt @@ -5,6 +5,7 @@ import com.itsaky.androidide.plugins.IPlugin import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.ai.prompt.AssetPromptConfigSource import com.itsaky.androidide.plugins.aicore.R +import com.itsaky.androidide.plugins.aicore.capabilities.CapabilityMonitor import com.itsaky.androidide.plugins.aicore.fragments.AiSettingsFragment import com.itsaky.androidide.plugins.aicore.fragments.ChatFragment import com.itsaky.androidide.plugins.aicore.prompt.PromptConfigChecks @@ -88,6 +89,12 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten /** The "< 2/3 >" version switcher on a user message whose older prompt was edited. */ const val TOOLTIP_TAG_MESSAGE_VERSIONS = "agent_message_versions" + // Tags for the capability row under the chat input (see CapabilityTagRowController). + const val TOOLTIP_TAG_CAPABILITY_BACKEND = "agent_capability_backend" + const val TOOLTIP_TAG_CAPABILITY_WEB = "agent_capability_web" + /** Every tool-source tag shares this one: MCP servers and plugins alike. */ + const val TOOLTIP_TAG_CAPABILITY_TOOLS = "agent_capability_tools" + // Tags for the interactive controls on the AI Settings screen (see AiSettingsFragment). const val TOOLTIP_TAG_SETTINGS_BACK = "ai_settings_back" const val TOOLTIP_TAG_SETTINGS_BACKEND = "ai_settings_backend" @@ -125,6 +132,7 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten registerToolSourceRegistry() preloadPromptConfig() + CapabilityMonitor.onServicesPublished() PathGuard.setProjectRootProvider { try { @@ -741,6 +749,52 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0) ) ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_CAPABILITY_BACKEND, + summary = "The backend and model that will answer your next message.", + detail = """ +

    Names the AI backend selected under Preferences → + Configuration → Agent, and the model it will use when the + backend reports one.

    +

    It turns red when that backend is not ready — no API key, no + model chosen, or its plugin uninstalled — so you can fix it before + sending anything. Tap it for the details and a shortcut to the + settings.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_CAPABILITY_WEB, + summary = "Whether the device is online, which network backends and MCP servers need.", + detail = """ +

    Shows Web while the device has a working internet + connection, and Offline, in red, while it does not.

    +

    A backend that runs on the device keeps working offline. A + network backend and every MCP server cannot be reached until the + connection comes back.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_CAPABILITY_TOOLS, + summary = "Extra tools the agent can use, from an MCP server or another plugin.", + detail = """ +

    Each MCP server you configured, and each plugin that adds tools + to the agent, gets its own tag. Tap one to see where it comes from + and how many tools it offers.

    +

    A red tag is set up but not working right now — a server that + cannot be reached, say — and the details say why. It clears by + itself once the server answers again. A tag disappears when you + switch the server or plugin off.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0) + ) + ), PluginTooltipEntry( tag = TOOLTIP_TAG_SETTINGS_BACK, summary = "Close the Agent settings and go back where you came from.", diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceImpl.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceImpl.kt index 32f816fe..c17b9c31 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceImpl.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceImpl.kt @@ -7,6 +7,7 @@ import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.LlmInferenceService.* import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArraySet /** * Implementation of LlmInferenceService. @@ -24,12 +25,53 @@ class LlmInferenceServiceImpl(private val logger: PluginLogger? = null) : LlmInf private val backends = ConcurrentHashMap() @Volatile private var currentGeneration: CompletableFuture? = null + /** + * Told of every register, unregister, selection and [notifyBackendChanged]. A set, so adding a + * listener twice is the no-op the contract promises. + */ + private val backendListeners = CopyOnWriteArraySet() + override fun registerBackend(backend: LlmBackend) { - backends[backend.getId()] = backend + val id = backend.getId() + backends[id] = backend + fireBackendChanged(id) } override fun unregisterBackend(backendId: String) { - backends.remove(backendId) + if (backends.remove(backendId) != null) fireBackendChanged(backendId) + } + + override fun addBackendChangeListener(listener: BackendChangeListener) { + backendListeners.add(listener) + } + + override fun removeBackendChangeListener(listener: BackendChangeListener) { + backendListeners.remove(listener) + } + + /** + * Relays a backend's own change — a key entered, a model loaded or picked — to the listeners. + * Also how [BackendRegistry.select] reports a new selection, which is a change to the same id. + * + * @param backendId the backend that changed; one that is not registered is ignored + */ + override fun notifyBackendChanged(backendId: String) { + if (backends.containsKey(backendId)) fireBackendChanged(backendId) + } + + /** + * Tells every listener, on the caller's thread and outside any lock, as the contract requires. + * One that throws is logged and skipped: it is another plugin's code, and must not stop the + * rest from hearing, nor surface in the backend plugin that reported the change. + */ + private fun fireBackendChanged(backendId: String) { + for (listener in backendListeners) { + try { + listener.onBackendChanged(backendId) + } catch (e: Throwable) { + logger?.error("A backend change listener threw for '$backendId'", e) + } + } } /** diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryImpl.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryImpl.kt index ec9abc31..2a44562f 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryImpl.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryImpl.kt @@ -10,6 +10,7 @@ import com.itsaky.androidide.plugins.aicore.tool.sources.ToolSourceStore import com.itsaky.androidide.plugins.services.ToolSourceRegistry import java.io.File import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArraySet private const val TAG = "$LOG_PREFIX.ToolSourceRegistry" @@ -32,6 +33,9 @@ class ToolSourceRegistryImpl( /** The host-side sources, kept in registration order so [getToolSources] can return them. */ private val hostSources = LinkedHashMap() + /** Consumers of what [getToolSources] describes — the chat's tag row, say. */ + private val listeners = CopyOnWriteArraySet() + override fun registerToolSource(source: ToolSourceRegistry.ToolSource) { val providerId = try { source.providerId.orEmpty().trim() @@ -53,6 +57,7 @@ class ToolSourceRegistryImpl( synchronized(lock) { hostSources[providerId] = source } store.register(HostToolSource(source, providerId, displayName)) + fireChanged(providerId) } override fun unregisterToolSource(source: ToolSourceRegistry.ToolSource) { @@ -66,13 +71,52 @@ class ToolSourceRegistryImpl( return } store.unregister(id) + fireChanged(id) } override fun getToolSources(): List = synchronized(lock) { hostSources.values.toList() } override fun notifyToolsChanged(providerId: String) { - store.toolsChanged(providerId.trim()) + val id = providerId.trim() + store.toolsChanged(id) + if (isRegistered(id)) fireChanged(id) + } + + /** + * Relays a provider's health change to the listeners only: the tool list is unchanged, so the + * agent's router, executor and grammar are not rebuilt for it. + */ + override fun notifyToolSourceStatusChanged(providerId: String) { + val id = providerId.trim() + if (isRegistered(id)) fire(id) { it.onToolSourceStatusChanged(id) } + } + + override fun addToolSourceListener(listener: ToolSourceRegistry.ToolSourceListener) { + listeners.add(listener) + } + + override fun removeToolSourceListener(listener: ToolSourceRegistry.ToolSourceListener) { + listeners.remove(listener) + } + + private fun isRegistered(providerId: String): Boolean = + synchronized(lock) { hostSources.containsKey(providerId) } + + private fun fireChanged(providerId: String) = fire(providerId) { it.onToolSourcesChanged(providerId) } + + /** + * Tells every listener, on the caller's thread and outside [lock], as the contract requires. + * One that throws is logged and skipped rather than reaching the provider that reported. + */ + private inline fun fire(providerId: String, call: (ToolSourceRegistry.ToolSourceListener) -> Unit) { + for (listener in listeners) { + try { + call(listener) + } catch (e: Throwable) { + Log.e(TAG, "A tool-source listener threw for '$providerId'", e) + } + } } /** diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt index 5818478e..6c6136a9 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt @@ -212,30 +212,6 @@ class ChatViewModel( private val currentBackendId: String get() = _backendStatus.value.id - /** - * Label for the backend the user *selected* in settings, shown under the chat input. Tracks the - * selection, not the availability-resolved backend: picking Gemini must read "Gemini API" before - * its key check runs, or it would always show "Local LLM". - */ - private val _activeBackendLabel = MutableStateFlow(selectedBackendLabel()) - val activeBackendLabel: StateFlow = _activeBackendLabel.asStateFlow() - - private fun selectedBackendLabel(): String = - // Resolved exactly as the settings screen and the availability check resolve it, so the - // three cannot name different backends on the same launch. - when (val selected = BackendRegistry.selected()) { - is SelectedBackend.Installed -> selected.option.displayName - // A stored selection resolving to nothing means its plugin is gone. Saying "no backend" - // there would read as "install one" when one is installed — just not the chosen one. - SelectedBackend.Missing -> str(R.string.backend_selected_missing_short) - SelectedBackend.None -> str(R.string.backend_none_installed_short) - } - - /** Re-read the selected backend and update [activeBackendLabel]; call when returning to chat. */ - fun refreshBackendLabel() { - _activeBackendLabel.value = selectedBackendLabel() - } - // Tool execution infrastructure private val approvalManager = ToolApprovalManager(sharedPromptConfig) { handler -> ToolDescriptions.describe(sharedPromptConfig.config(), RESPOND_TOOL, handler) diff --git a/plugins/AI-Core/src/main/res/drawable/ic_status_dot.xml b/plugins/AI-Core/src/main/res/drawable/ic_status_dot.xml new file mode 100644 index 00000000..f92c3bb8 --- /dev/null +++ b/plugins/AI-Core/src/main/res/drawable/ic_status_dot.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/plugins/AI-Core/src/main/res/layout/fragment_chat.xml b/plugins/AI-Core/src/main/res/layout/fragment_chat.xml index 237cf884..cf6df999 100644 --- a/plugins/AI-Core/src/main/res/layout/fragment_chat.xml +++ b/plugins/AI-Core/src/main/res/layout/fragment_chat.xml @@ -260,14 +260,25 @@ android:tint="@color/chat_send_tint" /> - + + android:contentDescription="@string/desc_capability_row" + android:scrollbars="none"> + + + diff --git a/plugins/AI-Core/src/main/res/values/dimens.xml b/plugins/AI-Core/src/main/res/values/dimens.xml index 3f9c384f..586cc24e 100644 --- a/plugins/AI-Core/src/main/res/values/dimens.xml +++ b/plugins/AI-Core/src/main/res/values/dimens.xml @@ -27,6 +27,13 @@ 22dp 12sp 4dp + + 28dp + 6dp + 12sp + 1dp + 8dp + 10dp 8 diff --git a/plugins/AI-Core/src/main/res/values/strings.xml b/plugins/AI-Core/src/main/res/values/strings.xml index 9693639b..9f52bded 100644 --- a/plugins/AI-Core/src/main/res/values/strings.xml +++ b/plugins/AI-Core/src/main/res/values/strings.xml @@ -179,6 +179,9 @@ Agent AI backend, model and API key Couldn\'t open Agent settings. Open them from Preferences → Configuration → Agent. + + MCP servers + Couldn\'t open %1$s. Open it from Preferences → Configuration → %1$s. AI Settings @@ -244,4 +247,31 @@ %1$s has no settings to configure. This backend\'s settings are unavailable. It may have been uninstalled or disabled. Error: %s + + + %1$s \u00b7 %2$s + Web + Offline + Working + Connecting + Not working + Not set up yet. Open the Agent settings to finish configuring it. + No AI backend is installed. Install one from the Plugin Manager. + The backend you selected is no longer installed. Choose another in the Agent settings. + The device is online, so network backends and MCP servers can be reached. + The device is offline. Network backends and MCP servers cannot be reached until it reconnects. + Backend + Web access + Source: %1$s + Model: %1$s + Model: not reported + Status: %1$s + Reason: %1$s + Settings + + %d tool + %d tools + + %1$s, %2$s. Tap for details. + What the agent is connected to diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityReadersTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityReadersTest.kt new file mode 100644 index 00000000..1d038cdd --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/capabilities/CapabilityReadersTest.kt @@ -0,0 +1,255 @@ +package com.itsaky.androidide.plugins.aicore.capabilities + +import com.itsaky.androidide.plugins.aicore.backends.BackendOption +import com.itsaky.androidide.plugins.aicore.backends.SelectedBackend +import com.itsaky.androidide.plugins.services.CapabilityStatus +import com.itsaky.androidide.plugins.services.LlmInferenceService +import com.itsaky.androidide.plugins.services.ToolSourceRegistry +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests how the host contracts become capability tags: one tag per MCP server rather than one + * for MCP, broken sources kept and marked, and a misbehaving provider costing only its own tag. + */ +class CapabilityReadersTest { + + private companion object { + const val MCP = "com.itsaky.androidide.plugins.aiagentmcp" + const val OTHER = "com.example.searchplugin" + } + + @Test + fun givenASourceWithGroups_whenRead_thenEachGroupIsItsOwnTag() { + val source = source( + MCP, + groups = listOf( + group("a", "GitHub", tools = 3, status = CapabilityStatus.AVAILABLE), + group("b", "Docs", tools = 0, status = CapabilityStatus.DEGRADED, message = "Docs is unreachable."), + ), + ) + + val tags = CapabilityReaders.tools(listOf(source)) + + assertEquals(listOf("GitHub", "Docs"), tags.map { it.name }) + assertEquals(listOf(3, 0), tags.map { it.toolCount }) + assertEquals(listOf(CapabilityStatus.AVAILABLE, CapabilityStatus.DEGRADED), tags.map { it.status }) + assertEquals("Docs is unreachable.", tags[1].statusMessage) + assertTrue(tags.all { it.sourceName == "MCP servers" }) + } + + @Test + fun givenASourceWithoutGroups_whenRead_thenItIsOneTagNamedForTheSource() { + val source = source(OTHER, name = "Search", tools = 2) + + val tag = CapabilityReaders.tools(listOf(source)).single() + + assertEquals("Search", tag.name) + assertEquals(2, tag.toolCount) + assertNull(tag.groupId) + } + + @Test + fun givenAGrouplessSourceOfferingNothing_whenRead_thenItHasNoTag() { + val source = source(MCP, tools = 0) + + assertEquals(emptyList(), CapabilityReaders.tools(listOf(source))) + } + + @Test + fun givenABrokenSourceOfferingNothing_whenRead_thenItKeepsADegradedTag() { + val source = source(OTHER, tools = 0, status = CapabilityStatus.DEGRADED) + + assertEquals(CapabilityStatus.DEGRADED, CapabilityReaders.tools(listOf(source)).single().status) + } + + @Test + fun givenASourceThatThrowsFromItsGroups_whenRead_thenTheOthersSurvive() { + val broken = source(OTHER, tools = 1).also { every { it.toolGroups } throws IllegalStateException("boom") } + val healthy = source(MCP, groups = listOf(group("a", "GitHub", tools = 1))) + + val tags = CapabilityReaders.tools(listOf(broken, healthy)) + + assertEquals(listOf("Search", "GitHub"), tags.map { it.name }) + } + + @Test + fun givenANullStatus_whenMapped_thenItReadsAsDegraded() { + assertEquals(CapabilityStatus.DEGRADED, CapabilityReaders.status(null)) + assertEquals(CapabilityStatus.CONNECTING, CapabilityReaders.status(CapabilityStatus.CONNECTING)) + } + + @Test + fun givenAReadyBackendReportingAModel_whenRead_thenTheTagCarriesIt() { + val service = service(backend("gemini", available = true, model = "gemini-2.5-flash")) + + val tag = CapabilityReaders.backend(installed("gemini", "Gemini API"), service) + + assertEquals("Gemini API", tag.name) + assertEquals("gemini-2.5-flash", tag.modelName) + assertEquals(CapabilityStatus.AVAILABLE, tag.status) + } + + @Test + fun givenABackendThatIsNotConfigured_whenRead_thenItIsDegraded() { + val service = service(backend("openai", available = false, model = null)) + + val tag = CapabilityReaders.backend(installed("openai", "OpenAI"), service) + + assertEquals(BackendState.NOT_CONFIGURED, tag.state) + assertEquals(CapabilityStatus.DEGRADED, tag.status) + assertNull(tag.modelName) + } + + @Test + fun givenABackendThatThrowsFromItsModelName_whenRead_thenOnlyTheModelIsMissing() { + val backend = backend("local", available = true, model = null).also { + every { it.activeModelName } throws IllegalStateException("boom") + } + + val tag = CapabilityReaders.backend(installed("local", "Local LLM"), service(backend)) + + assertEquals(BackendState.READY, tag.state) + assertNull(tag.modelName) + } + + @Test + fun givenAReadyBackendWhoseServerIsDown_whenRead_thenItIsDegradedWithItsReason() { + val backend = backend( + "openai", available = true, model = "qwen2.5-coder", + status = CapabilityStatus.DEGRADED, message = "Nothing answered.", + ) + + val tag = CapabilityReaders.backend(installed("openai", "OpenAI"), service(backend)) + + assertEquals(BackendState.READY, tag.state) + assertEquals(CapabilityStatus.DEGRADED, tag.status) + assertEquals("Nothing answered.", tag.statusMessage) + } + + @Test + fun givenAReadyBackendStillBeingChecked_whenRead_thenItIsConnecting() { + val backend = backend( + "openai", available = true, model = null, status = CapabilityStatus.CONNECTING, + ) + + val tag = CapabilityReaders.backend(installed("openai", "OpenAI"), service(backend)) + + assertEquals(CapabilityStatus.CONNECTING, tag.status) + } + + @Test + fun givenABackendThatThrowsFromItsStatus_whenRead_thenItIsTakenAsAvailable() { + val backend = backend("gemini", available = true, model = null).also { + every { it.status } throws IllegalStateException("boom") + } + + val tag = CapabilityReaders.backend(installed("gemini", "Gemini API"), service(backend)) + + assertEquals(CapabilityStatus.AVAILABLE, tag.status) + assertNull(tag.statusMessage) + } + + @Test + fun givenABackendThatReportsNeitherStatusNorModel_whenRead_thenItIsAvailableWithNoModel() { + val backend = mockk { + every { getId() } returns "gemini" + every { isAvailable() } returns true + } + + val tag = CapabilityReaders.backend(installed("gemini", "Gemini API"), service(backend)) + + assertEquals(CapabilityStatus.AVAILABLE, tag.status) + assertNull(tag.statusMessage) + assertNull(tag.modelName) + } + + @Test + fun givenASourceThatReportsNeitherStatusNorGroups_whenRead_thenItIsOneAvailableTag() { + val plain = mockk { + every { providerId } returns OTHER + every { displayName } returns "Search" + every { listTools() } returns List(2) { mockk() } + } + + val tag = CapabilityReaders.tools(listOf(plain)).single() + + assertEquals(CapabilityStatus.AVAILABLE, tag.status) + assertEquals(2, tag.toolCount) + assertNull(tag.groupId) + } + + @Test + fun givenNoBackendInstalled_whenRead_thenTheTagSaysSo() { + val tag = CapabilityReaders.backend(SelectedBackend.None, null) + + assertEquals(BackendState.NONE_INSTALLED, tag.state) + assertEquals(CapabilityStatus.DEGRADED, tag.status) + } + + private fun installed(id: String, name: String) = + SelectedBackend.Installed(BackendOption(id, name, null, null)) + + private fun service(backend: LlmInferenceService.LlmBackend): LlmInferenceService { + // Read before recording: a call on another mock inside `every` is recorded as part of it. + val id = backend.id + return mockk { every { getBackend(id) } returns backend } + } + + private fun backend( + id: String, + available: Boolean, + model: String?, + status: CapabilityStatus = CapabilityStatus.AVAILABLE, + message: String? = null, + ) = mockk { + every { getId() } returns id + every { isAvailable() } returns available + every { activeModelName } returns model + every { getStatus() } returns status + every { statusMessage } returns message + } + + private fun source( + id: String, + name: String = if (id == MCP) "MCP servers" else "Search", + tools: Int = 0, + status: CapabilityStatus = CapabilityStatus.AVAILABLE, + groups: List = emptyList(), + ) = mockk { + every { providerId } returns id + every { displayName } returns name + every { listTools() } returns List(tools) { mockk() } + every { this@mockk.status } returns status + every { statusMessage } returns null + every { toolGroups } returns groups + } + + /** A backend that reports both status and model, as the OpenAI plugin's does. */ + private interface FullBackend : + LlmInferenceService.StatusReportingBackend, + LlmInferenceService.ActiveModelReportingBackend + + /** A source that reports both status and groups, as the MCP plugin's does. */ + private interface FullSource : + ToolSourceRegistry.StatusReportingToolSource, + ToolSourceRegistry.GroupedToolSource + + private fun group( + id: String, + name: String, + tools: Int, + status: CapabilityStatus = CapabilityStatus.AVAILABLE, + message: String? = null, + ) = mockk { + every { this@mockk.id } returns id + every { displayName } returns name + every { toolNames } returns List(tools) { "tool_$it" } + every { this@mockk.status } returns status + every { statusMessage } returns message + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceListenerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceListenerTest.kt new file mode 100644 index 00000000..3f0e6b41 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/services/LlmInferenceServiceListenerTest.kt @@ -0,0 +1,93 @@ +package com.itsaky.androidide.plugins.aicore.services + +import com.itsaky.androidide.plugins.services.LlmInferenceService.BackendChangeListener +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmBackend +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * Tests the backend-change listeners the chat's capability tags depend on: every register, + * unregister and reported change reaches them, and nothing else does. + */ +class LlmInferenceServiceListenerTest { + + private lateinit var service: LlmInferenceServiceImpl + private val heard = mutableListOf() + private val listener = BackendChangeListener { heard += it } + + @Before + fun setUp() { + service = LlmInferenceServiceImpl() + service.addBackendChangeListener(listener) + } + + @Test + fun givenAListener_whenABackendRegistersAndUnregisters_thenItHearsBoth() { + service.registerBackend(backend("gemini")) + service.unregisterBackend("gemini") + + assertEquals(listOf("gemini", "gemini"), heard) + } + + @Test + fun givenAListener_whenAnUnknownBackendUnregisters_thenItHearsNothing() { + service.unregisterBackend("nobody") + + assertEquals(emptyList(), heard) + } + + @Test + fun givenARegisteredBackend_whenItReportsAChange_thenTheListenerHearsItsId() { + service.registerBackend(backend("local")) + heard.clear() + + service.notifyBackendChanged("local") + + assertEquals(listOf("local"), heard) + } + + @Test + fun givenAnUnregisteredId_whenAChangeIsReported_thenItIsIgnored() { + service.notifyBackendChanged("nobody") + + assertEquals(emptyList(), heard) + } + + @Test + fun givenARemovedListener_whenABackendRegisters_thenItIsNotCalled() { + service.removeBackendChangeListener(listener) + + service.registerBackend(backend("gemini")) + + assertEquals(emptyList(), heard) + } + + @Test + fun givenTheSameListenerAddedTwice_whenABackendRegisters_thenItIsCalledOnce() { + service.addBackendChangeListener(listener) + + service.registerBackend(backend("gemini")) + + assertEquals(listOf("gemini"), heard) + } + + @Test + fun givenAListenerThatThrows_whenABackendRegisters_thenTheOthersStillHearAndNothingEscapes() { + val throwing = BackendChangeListener { throw IllegalStateException("boom") } + service.removeBackendChangeListener(listener) + service.addBackendChangeListener(throwing) + service.addBackendChangeListener(listener) + + service.registerBackend(backend("gemini")) + + assertEquals(listOf("gemini"), heard) + } + + private fun backend(id: String) = mockk(relaxed = true) { + every { getId() } returns id + every { getName() } returns id + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryListenerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryListenerTest.kt new file mode 100644 index 00000000..d773294e --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryListenerTest.kt @@ -0,0 +1,152 @@ +package com.itsaky.androidide.plugins.aicore.services + +import com.itsaky.androidide.plugins.aicore.tool.sources.ToolSourceStore +import com.itsaky.androidide.plugins.services.CapabilityStatus +import com.itsaky.androidide.plugins.services.ToolSourceRegistry +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * Tests the registry's consumer listeners (contract 2): told on register, unregister, a tools + * change and a status change, and only for a provider that is actually registered. + */ +class ToolSourceRegistryListenerTest { + + private companion object { + const val MCP = "com.itsaky.androidide.plugins.aiagentmcp" + } + + private lateinit var store: ToolSourceStore + private lateinit var registry: ToolSourceRegistryImpl + private val heard = mutableListOf() + private val listener = ToolSourceRegistry.ToolSourceListener { heard += it } + + @Before + fun setUp() { + store = ToolSourceStore() + registry = ToolSourceRegistryImpl(store) + registry.addToolSourceListener(listener) + } + + @Test + fun givenAListener_whenASourceRegistersAndUnregisters_thenItHearsBoth() { + val source = source(MCP) + + registry.registerToolSource(source) + registry.unregisterToolSource(source) + + assertEquals(listOf(MCP, MCP), heard) + } + + @Test + fun givenARegisteredSource_whenItsToolsOrStatusChange_thenTheListenerHearsEach() { + registry.registerToolSource(source(MCP)) + heard.clear() + + registry.notifyToolsChanged(MCP) + registry.notifyToolSourceStatusChanged(MCP) + + assertEquals(listOf(MCP, MCP), heard) + } + + @Test + fun givenAStatusChange_whenItArrives_thenTheAgentsToolSetIsNotRebuilt() { + registry.registerToolSource(source(MCP)) + var rebuilds = 0 + store.addChangeListener { rebuilds++ } + + registry.notifyToolSourceStatusChanged(MCP) + + assertEquals(0, rebuilds) + } + + @Test + fun givenAnUnknownProvider_whenItReportsAChange_thenNothingIsHeard() { + registry.notifyToolsChanged("com.example.nobody") + registry.notifyToolSourceStatusChanged("com.example.nobody") + + assertEquals(emptyList(), heard) + } + + @Test + fun givenARemovedListener_whenASourceRegisters_thenItIsNotCalled() { + registry.removeToolSourceListener(listener) + + registry.registerToolSource(source(MCP)) + + assertEquals(emptyList(), heard) + } + + @Test + fun givenAListenerThatThrows_whenASourceRegisters_thenTheOthersStillHear() { + registry.removeToolSourceListener(listener) + registry.addToolSourceListener { throw IllegalStateException("boom") } + registry.addToolSourceListener(listener) + + registry.registerToolSource(source(MCP)) + + assertEquals(listOf(MCP), heard) + } + + @Test + fun givenARegisteredSource_whenTheRegistryListsIt_thenItsGroupsAndStatusPassThroughUnchanged() { + val group = mockk() + val source = source(MCP).also { + every { it.toolGroups } returns listOf(group) + every { it.status } returns CapabilityStatus.DEGRADED + } + + registry.registerToolSource(source) + val listed = registry.toolSources.single() as FullSource + + assertEquals(listOf(group), listed.toolGroups) + assertEquals(CapabilityStatus.DEGRADED, listed.status) + } + + @Test + fun givenAListenerThatTellsThemApart_whenTheStatusChanges_thenOnlyItsStatusCallbackRuns() { + registry.removeToolSourceListener(listener) + val tools = mutableListOf() + val status = mutableListOf() + registry.addToolSourceListener(object : ToolSourceRegistry.ToolSourceListener { + override fun onToolSourcesChanged(providerId: String) { + tools += providerId + } + + override fun onToolSourceStatusChanged(providerId: String) { + status += providerId + } + }) + registry.registerToolSource(source(MCP)) + tools.clear() + + registry.notifyToolSourceStatusChanged(MCP) + registry.notifyToolsChanged(MCP) + + assertEquals(listOf(MCP), status) + assertEquals(listOf(MCP), tools) + } + + @Test + fun givenAListener_whenTheSameOneIsAddedTwice_thenItHearsEachChangeOnce() { + registry.addToolSourceListener(listener) + + registry.registerToolSource(source(MCP)) + + assertEquals(listOf(MCP), heard) + } + + /** A source that reports both status and groups, as the MCP plugin's does. */ + private interface FullSource : + ToolSourceRegistry.StatusReportingToolSource, + ToolSourceRegistry.GroupedToolSource + + private fun source(id: String) = mockk(relaxed = true) { + every { providerId } returns id + every { displayName } returns "MCP servers" + every { listTools() } returns emptyList() + } +}