diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 708b80164b..d5b77cb544 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -36,6 +36,67 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed] = diffed from `plugin-api/src` history (predates the dump; symbol-accurate). ### 26.41 — unreleased +- **added — Tool-source groups and health, backend model names, and change listeners** _(ADFA-6278)_ **[verified]** + A consumer such as the agent's chat screen could not tell which tools the agent has, whether + they work, or which model will answer, and was never told when any of that changed. + `ToolSourceRegistry.CONTRACT_VERSION` is now `2`. + - `ToolSourceRegistry.GroupedToolSource` (new interface, extends `ToolSource`): + `getToolGroups()` splits one source into `ToolSourceRegistry.ToolGroup`s (`getId`, + `getDisplayName`, `getToolNames`, `getStatus`, `getStatusMessage`) — one per MCP server, + say. A group may name no tools, which is how an unreachable server keeps its place. + - `ToolSourceRegistry.StatusReportingToolSource` (new interface, extends `ToolSource`): + `getStatus()` and `getStatusMessage()` report health as a `CapabilityStatus`: + `AVAILABLE`, `CONNECTING` or `DEGRADED`. Both must be cheap and non-blocking, because + consumers call them on the UI thread. A provider reports a change with + `ToolSourceRegistry.notifyToolSourceStatusChanged`. The enum may grow; handle an unknown + constant as `DEGRADED`. A source that does not implement it reads as `AVAILABLE`. + - `ToolSourceRegistry.addToolSourceListener` / `removeToolSourceListener` take a + `ToolSourceListener`. `onToolSourcesChanged` is told the provider id on register, + unregister and `notifyToolsChanged`; `onToolSourceStatusChanged` on + `notifyToolSourceStatusChanged`, so a status change need not re-read the tool list. It + defaults to `onToolSourcesChanged`. + - `LlmInferenceService.ActiveModelReportingBackend` (new interface, extends `LlmBackend`): + `getActiveModelName()` names the model a backend will answer with. It is not called + `getModelName` because the Gemini and OpenAI backends already declare a private + `getModelName()`. A backend that does not implement it names no model. + - `LlmInferenceService.StatusReportingBackend` (new interface, extends `LlmBackend`): + `getStatus()` and `getStatusMessage()` report whether the backend can answer right now as + the same `CapabilityStatus`. A backend + that does not implement it reads as `AVAILABLE`. `isAvailable()` still means only "set up"; a + configured OpenAI-compatible server that is not running is `DEGRADED`. Same cheap, + non-blocking rule and unknown-constant reading as for a tool source. + - `com.itsaky.androidide.plugins.services.CapabilityStatus` (new top-level enum) is the one + status both contracts return, so a consumer showing backends and tool sources side by side + maps one vocabulary, not two identical enums. + - `com.itsaky.androidide.plugins.ai.LlmBackendRegistration` keeps a backend plugin's + `LlmBackend` registered with the router: `start(backend)` from `activate`, `stop()` from + `deactivate` and `dispose`. It re-registers when AI Core restarts, and calls + `notifyBackendChanged` when one of the `watchedKeys` in the plugin's settings changes, so + backend plugins stop carrying their own copy of that wiring. + - `LlmInferenceService.addBackendChangeListener` / `removeBackendChangeListener` take a + `BackendChangeListener`, which is told the backend id on register, unregister, a change + in the user's selection, and `LlmInferenceService.notifyBackendChanged`. A backend calls + that when its availability or model changes. + - `LlmInferenceService.EmbeddingModelSelectable` (new interface, extends `EmbeddingBackend`) + lets a screen outside the backend's plugin list (`listEmbeddingModels()`) and change + (`setEmbeddingModelId`) the embedding model; the backend still stores the choice and calls + `notifyBackendChanged`. `EmbeddingBackend.getEmbeddingDimensions()` is now constant per + model rather than per backend instance. + + Listeners are called synchronously on the changing thread, outside the implementation's + lock. An exception a listener throws is contained. + + Status, model name and groups are new interfaces rather than defaults on `ToolSource` and + `LlmBackend`, per the "prefer a new interface" rule in [plugin-api.md](plugin-api.md): a + default named `getStatusMessage`, `getActiveModelName` or `getToolGroups` could be taken for + an unrelated method a plugin already declares. `LlmBackend` and `ToolSource` gain no + members, and the ABI dump diff is additions only, so a plugin built against 26.40 loads + unchanged. + + The registry and service defaults do nothing, and ai-core supplies the real behaviour. On an + ai-core built before this change, listeners are accepted and never called. Floor + `plugin.min_ide_version` at `26.41` to rely on the new members. + - **added — The AI prompt config engine and settings-pane helpers** _(ADFA-6281)_ **[verified]** Every AI plugin carried its own copy of the code that reads and renders its prompt config, and the credential screens their own copy of the reveal toggle and pane diff --git a/docs/plugin-api.md b/docs/plugin-api.md index 3d8aae5450..17f98f6c96 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -12,8 +12,8 @@ The surface a plugin binds to is broader than one module. All of the following a - Core: `IPlugin` (lifecycle), `PluginContext`, `PluginLogger`, `ServiceRegistry`, `ResourceManager`. - Extension interfaces plugins **implement**: `UIExtension`, `EditorExtension`, `EditorTabExtension`, `DocumentationExtension`, `BuildActionExtension`, `SnippetExtension`, `ProjectExtension`, `FileOpenExtension`, `SettingsExtension`. - IDE service interfaces plugins **call** (via `ServiceRegistry.get(X::class.java)`): `IdeProjectService`, `IdeEditorService`, `IdeFileService`, `IdeEnvironmentService`, `IdeArchiveService`, `IdeBuildService`, `IdeLogService`, `IdeUIService`, `IdeEditorTabService`, `IdeTooltipService`, `IdeThemeService`, `IdeFeatureFlagService`, `IdeCommandService`, `IdeTemplateService`, `IdeSnippetService`, `IdeSidebarService`. - - Cross-plugin service interfaces, where **one plugin implements what another calls** (via `SharedServices`): `LlmInferenceService` — implemented by ai-core, called by every AI plugin — together with the types nested in it that a *backend* plugin implements (`LlmBackend`, `HistoryCapableBackend`, `ToolCallingBackend`, `CancellableBackend`, `ConfigurableBackend`, `EmbeddingBackend`) and the value types either side constructs (`ChatMessage`, `LlmConfig`, `LlmResponse`, `SystemPromptRequest`, `ToolDefinition`, `ToolCallRequest`). Also `ToolSourceRegistry` — implemented by ai-core, called by any plugin contributing tools to the agent — with `ToolSource` and `ToolSpec`, which a *contributing* plugin implements, `ToolInvocation`, which ai-core constructs and passes to `ToolSource.invoke`, and `ToolOutcome`, which the source returns. - - Utility classes plugins **instantiate**: `KeystoreSecretStore` (AES/GCM over the Android Keystore, alias supplied by the caller); the AI prompt config engine in `ai.prompt` (`PromptTemplateEngine`, `PromptConfigLoader`, `PromptConfigStore`, `PromptConfigDocument`, `PromptConfigObject`, `AssetPromptConfigSource`), generic over a plugin's own config type through `PromptConfigParser` and `PromptConfigProvider`, which a plugin implements; and the settings-pane helpers in `ai.ui` (`SecretRevealController` with `RevealToggle`, `applyPaneStyling` with `PaneStyle`, `ButtonColors` and `FieldColors`). Host-side implementations rather than interfaces, so plugins share one copy in the process instead of compiling their own. + - Cross-plugin service interfaces, where **one plugin implements what another calls** (via `SharedServices`): `LlmInferenceService` — implemented by ai-core, called by every AI plugin — together with the types nested in it that a *backend* plugin implements (`LlmBackend`, `HistoryCapableBackend`, `ToolCallingBackend`, `CancellableBackend`, `ConfigurableBackend`, `EmbeddingBackend`, `EmbeddingModelSelectable`, `StatusReportingBackend`, `ActiveModelReportingBackend`), `BackendChangeListener`, which a *consumer* implements, and the value types either side constructs (`ChatMessage`, `LlmConfig`, `LlmResponse`, `SystemPromptRequest`, `ToolDefinition`, `ToolCallRequest`). Also `ToolSourceRegistry` — implemented by ai-core, called by any plugin contributing tools to the agent — with `ToolSource`, `StatusReportingToolSource`, `GroupedToolSource`, `ToolSpec` and `ToolGroup`, which a *contributing* plugin implements, `ToolSourceListener`, which a *consumer* implements, `ToolInvocation`, which ai-core constructs and passes to `ToolSource.invoke`, and `ToolOutcome`, which the source returns. Both contracts report health as the top-level `CapabilityStatus` enum. + - Utility classes plugins **instantiate**: `KeystoreSecretStore` (AES/GCM over the Android Keystore, alias supplied by the caller); the AI prompt config engine in `ai.prompt` (`PromptTemplateEngine`, `PromptConfigLoader`, `PromptConfigStore`, `PromptConfigDocument`, `PromptConfigObject`, `AssetPromptConfigSource`), generic over a plugin's own config type through `PromptConfigParser` and `PromptConfigProvider`, which a plugin implements; and the settings-pane helpers in `ai.ui` (`SecretRevealController` with `RevealToggle`, `applyPaneStyling` with `PaneStyle`, `ButtonColors` and `FieldColors`); and `ai.LlmBackendRegistration`, which keeps a backend plugin registered with AI Core's router. Host-side implementations rather than interfaces, so plugins share one copy in the process instead of compiling their own. - Data classes plugins **construct** (e.g. `MenuItem`, `TabItem`, `EditorTabItem`, `NavigationItem`, `ToolbarAction`, `FabAction`, `PluginBuildAction`, `SnippetContribution`, `PluginTooltipEntry`, `PluginSettingsEntry`). - Enums / sealed types plugins **reference**: `PluginPermission`, `ShowAsAction`, `ArchiveFormat`, `BuildActionCategory`, `ToolbarActionIds`, `CommandSpec`, `CommandResult`, `ExtractResult`, `KeystoreSecretStore.Stored`. Sealed, so a plugin `when`s over the cases exhaustively — adding one is a **breaking** change, not an additive one. - **Wire/format contracts outside the module:** @@ -40,7 +40,7 @@ These look source-compatible but break already-built `.cgp` plugins: - **Interface methods — direction matters.** Ask who implements the interface before you apply a rule; the answer is not "host" just because the name ends in `Service`. - *Extension interfaces* (`UIExtension`, `BuildActionExtension`, …) are implemented **by plugins**: adding a method is breaking for them (even a defaulted one can break depending on compilation). Provide defaults and prefer additive optional hooks. - *Host service interfaces* (`Ide*Service`) are implemented **by the host** and only called by plugins: **adding** a method is safe; changing or removing a signature is breaking. - - *Plugin-implemented service interfaces* (`LlmInferenceService` and the backend interfaces nested in it) are implemented **by a plugin** even though they are shaped like services. The extension-interface rule applies, not the host-service one: **adding** a method is breaking. A Kotlin implementor's existing method loses its `override` when a Java `default` appears above it, so the break is a compile error in the *other* repo — which the impact check below is what catches. Prefer a new interface extending the old one over a new method on it. + - *Plugin-implemented service interfaces* (`LlmInferenceService` and the backend interfaces nested in it) are implemented **by a plugin** even though they are shaped like services. The extension-interface rule applies, not the host-service one: **adding** a method is breaking. A Kotlin implementor that already declares a same-signature method without `override` stops compiling when a Java `default` appears above it, so the break is in the *other* repo — the impact check below catches it. Prefer a new interface extending the old one over a new method on it: `StatusReportingBackend`, `ActiveModelReportingBackend` and `StatusReportingToolSource` follow this, and the consumer asks with `instanceof`. The same rule covers `ToolSource`, which every contributing plugin implements. `LlmInferenceService` and `ToolSourceRegistry` themselves took `default` methods in 26.41 as an exception: AI Core is their only implementor, and its released implementations were checked to declare none of the new names. - **Enum constants.** Removing or renaming a constant (`PluginPermission`, `ShowAsAction`, `ArchiveFormat`, `ToolbarActionIds`, `BuildActionCategory`) breaks plugins that name it; adding one can still break an exhaustive `when`. - **Types & nullability.** Flipping nullable↔non-null, changing a parameter/return type, or `val`↔`var` on an API property. - **Moving or renaming** any class/package under `com.itsaky.androidide.plugins.*` — breaks imports and `ServiceRegistry.get(...)` lookups. diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index d710d3f2bc..8fa5072a4b 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -137,6 +137,20 @@ public abstract interface class com/itsaky/androidide/plugins/ServiceRegistry { public abstract fun unregister (Ljava/lang/Class;)V } +public final class com/itsaky/androidide/plugins/ai/LlmBackendRegistration { + public static final field AI_CORE_PLUGIN_ID Ljava/lang/String; + public static final field Companion Lcom/itsaky/androidide/plugins/ai/LlmBackendRegistration$Companion; + public fun (Lcom/itsaky/androidide/plugins/PluginContext;Lkotlin/jvm/functions/Function0;Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lcom/itsaky/androidide/plugins/PluginContext;Lkotlin/jvm/functions/Function0;Ljava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun isRegistered ()Z + public final fun notifyBackendChanged ()V + public final fun start (Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend;)Z + public final fun stop ()V +} + +public final class com/itsaky/androidide/plugins/ai/LlmBackendRegistration$Companion { +} + public final class com/itsaky/androidide/plugins/ai/prompt/AssetPromptConfigSource : com/itsaky/androidide/plugins/ai/prompt/PromptConfigSource { public static final field Companion Lcom/itsaky/androidide/plugins/ai/prompt/AssetPromptConfigSource$Companion; public static final field DEFAULT_ROOT Ljava/lang/String; @@ -1476,6 +1490,14 @@ public abstract interface class com/itsaky/androidide/plugins/services/BuildStat public abstract fun onBuildStarted ()V } +public final class com/itsaky/androidide/plugins/services/CapabilityStatus : java/lang/Enum { + public static final field AVAILABLE Lcom/itsaky/androidide/plugins/services/CapabilityStatus; + public static final field CONNECTING Lcom/itsaky/androidide/plugins/services/CapabilityStatus; + public static final field DEGRADED Lcom/itsaky/androidide/plugins/services/CapabilityStatus; + public static fun valueOf (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/CapabilityStatus; + public static fun values ()[Lcom/itsaky/androidide/plugins/services/CapabilityStatus; +} + public abstract interface class com/itsaky/androidide/plugins/services/CommandExecution { public abstract fun await (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun cancel ()V @@ -1747,6 +1769,7 @@ public final class com/itsaky/androidide/plugins/services/IdeUIService$DefaultIm } public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService { + public fun addBackendChangeListener (Lcom/itsaky/androidide/plugins/services/LlmInferenceService$BackendChangeListener;)V public abstract fun cancelGeneration ()V public abstract fun generateCompletion (Ljava/lang/String;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;)Ljava/util/concurrent/CompletableFuture; public abstract fun generateStreaming (Ljava/lang/String;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$StreamCallback;)V @@ -1757,10 +1780,20 @@ public abstract interface class com/itsaky/androidide/plugins/services/LlmInfere public abstract fun getEmbeddings (Ljava/lang/String;Ljava/lang/String;)Ljava/util/concurrent/CompletableFuture; public fun getPreferredBackendId ()Ljava/lang/String; public abstract fun isBackendAvailable (Ljava/lang/String;)Z + public fun notifyBackendChanged (Ljava/lang/String;)V public abstract fun registerBackend (Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend;)V + public fun removeBackendChangeListener (Lcom/itsaky/androidide/plugins/services/LlmInferenceService$BackendChangeListener;)V public abstract fun unregisterBackend (Ljava/lang/String;)V } +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$ActiveModelReportingBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public abstract fun getActiveModelName ()Ljava/lang/String; +} + +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$BackendChangeListener { + public abstract fun onBackendChanged (Ljava/lang/String;)V +} + public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$CancellableBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { public abstract fun cancelStreaming ()V } @@ -1794,6 +1827,11 @@ public abstract interface class com/itsaky/androidide/plugins/services/LlmInfere public static fun requireValidBatch (Ljava/util/List;)Ljava/util/List; } +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$EmbeddingModelSelectable : com/itsaky/androidide/plugins/services/LlmInferenceService$EmbeddingBackend { + public abstract fun listEmbeddingModels ()Ljava/util/concurrent/CompletableFuture; + public abstract fun setEmbeddingModelId (Ljava/lang/String;)V +} + public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$HistoryCapableBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { public abstract fun generateStreamingWithHistory (Ljava/util/List;Ljava/lang/String;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$StreamCallback;)V } @@ -1831,6 +1869,11 @@ public class com/itsaky/androidide/plugins/services/LlmInferenceService$LlmRespo public static fun success (Ljava/lang/String;IJ)Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmResponse; } +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$StatusReportingBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public abstract fun getStatus ()Lcom/itsaky/androidide/plugins/services/CapabilityStatus; + public abstract fun getStatusMessage ()Ljava/lang/String; +} + public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$StreamCallback { public abstract fun onComplete (Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmResponse;)V public abstract fun onError (Ljava/lang/String;)V @@ -2006,12 +2049,32 @@ public abstract interface class com/itsaky/androidide/plugins/services/ThemeChan public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry { public static final field CONTRACT_VERSION I + public fun addToolSourceListener (Lcom/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSourceListener;)V public abstract fun getToolSources ()Ljava/util/List; + public fun notifyToolSourceStatusChanged (Ljava/lang/String;)V public abstract fun notifyToolsChanged (Ljava/lang/String;)V public abstract fun registerToolSource (Lcom/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSource;)V + public fun removeToolSourceListener (Lcom/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSourceListener;)V public abstract fun unregisterToolSource (Lcom/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSource;)V } +public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry$GroupedToolSource : com/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSource { + public abstract fun getToolGroups ()Ljava/util/List; +} + +public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry$StatusReportingToolSource : com/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSource { + public abstract fun getStatus ()Lcom/itsaky/androidide/plugins/services/CapabilityStatus; + public abstract fun getStatusMessage ()Ljava/lang/String; +} + +public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolGroup { + public abstract fun getDisplayName ()Ljava/lang/String; + public abstract fun getId ()Ljava/lang/String; + public fun getStatus ()Lcom/itsaky/androidide/plugins/services/CapabilityStatus; + public fun getStatusMessage ()Ljava/lang/String; + public abstract fun getToolNames ()Ljava/util/List; +} + public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolInvocation { public abstract fun getArguments ()Ljava/util/Map; public abstract fun getCallId ()Ljava/lang/String; @@ -2033,6 +2096,11 @@ public abstract interface class com/itsaky/androidide/plugins/services/ToolSourc public abstract fun listTools ()Ljava/util/List; } +public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSourceListener { + public fun onToolSourceStatusChanged (Ljava/lang/String;)V + public abstract fun onToolSourcesChanged (Ljava/lang/String;)V +} + public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSpec { public abstract fun getDescription ()Ljava/lang/String; public abstract fun getName ()Ljava/lang/String; diff --git a/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/CapabilityStatus.java b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/CapabilityStatus.java new file mode 100644 index 0000000000..cfac616484 --- /dev/null +++ b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/CapabilityStatus.java @@ -0,0 +1,16 @@ +package com.itsaky.androidide.plugins.services; + +/** + * Whether something the agent depends on can be used right now: a {@link LlmInferenceService.StatusReportingBackend}, a {@link ToolSourceRegistry.StatusReportingToolSource} or one of its {@link ToolSourceRegistry.ToolGroup}s. + * + *

+ * One enum for both contracts, so a consumer that shows backends and tool sources side by side reads one vocabulary. More states may be added later, so a consumer must handle a constant it does not know; treating it as {@link #DEGRADED} is the safe reading. + */ +public enum CapabilityStatus { + /** Working, or not checked by its provider: a request or tool call is expected to go through. */ + AVAILABLE, + /** Configured and being checked or connected; not failed, but a request may not go through yet. */ + CONNECTING, + /** Configured but not working -- unreachable, refusing, misconfigured. It keeps its place so the user sees what is broken. */ + DEGRADED +} diff --git a/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/LlmInferenceService.java b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/LlmInferenceService.java index 54a50a652d..99ead62390 100644 --- a/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/LlmInferenceService.java +++ b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/LlmInferenceService.java @@ -13,10 +13,21 @@ * Service for LLM inference operations. Provided by ai-core plugin. * *

- * {@link LlmBackend} is the one type here that plugins implement rather than call, so it carries only what every backend can answer. Anything a backend may or may not do is a separate interface extending it -- {@link HistoryCapableBackend}, {@link ToolCallingBackend}, {@link CancellableBackend}, {@link ConfigurableBackend}, {@link EmbeddingBackend}, {@link WebSearchBackend} -- and the consumer asks with {@code instanceof} before it calls. A capability is therefore declared by the type, not by a flag a backend can set inconsistently with the methods it overrode. + * {@link LlmBackend} is the one type here that plugins implement rather than call, so it carries only what every backend can answer. Anything a backend may or may not do is a separate interface extending it -- {@link ActiveModelReportingBackend}, {@link HistoryCapableBackend}, {@link ToolCallingBackend}, {@link CancellableBackend}, {@link ConfigurableBackend}, {@link EmbeddingBackend}, {@link StatusReportingBackend}, {@link WebSearchBackend} -- and the consumer asks with {@code instanceof} before it calls. A capability is therefore declared by the type, not by a flag a backend can set inconsistently with the methods it overrode. */ public interface LlmInferenceService { + /** + * Adds a listener told whenever the backends change: one registers or unregisters, reports a change through {@link #notifyBackendChanged}, or the user selects a different one ({@link #getPreferredBackendId}). Adding a listener already added has no effect. + * + *

+ * Defaults to doing nothing, which is what a service built before this method provides: its listeners are never called, and a consumer must read {@link #getAvailableBackends} when it needs it. + * + * @param listener + * the listener to add (must not be null) + */ + default void addBackendChangeListener(@NonNull BackendChangeListener listener) {} + /** * Cancels any ongoing generation operation. */ @@ -136,6 +147,17 @@ default String getPreferredBackendId() { */ boolean isBackendAvailable(@NonNull String backendId); + /** + * Signals that a registered backend's {@link LlmBackend#isAvailable}, {@link StatusReportingBackend#getStatus} or {@link ActiveModelReportingBackend#getActiveModelName} has changed and must be read again -- a key was entered, a local model finished loading, the user picked another model. The service tells every {@link BackendChangeListener}. + * + *

+ * Defaults to doing nothing, which is what a service built before this method provides. + * + * @param backendId + * the {@link LlmBackend#getId} that changed; unknown ids are ignored + */ + default void notifyBackendChanged(@NonNull String backendId) {} + /** * Registers an LLM backend with the service. * @@ -144,6 +166,17 @@ default String getPreferredBackendId() { */ void registerBackend(@NonNull LlmBackend backend); + /** + * Removes a listener previously passed to {@link #addBackendChangeListener}; it is not called again once this returns. A listener that is not added is ignored. + * + *

+ * Defaults to doing nothing, matching {@link #addBackendChangeListener}. + * + * @param listener + * the listener to remove (must not be null) + */ + default void removeBackendChangeListener(@NonNull BackendChangeListener listener) {} + /** * Unregisters an LLM backend from the service. * @@ -152,6 +185,46 @@ default String getPreferredBackendId() { */ void unregisterBackend(@NonNull String backendId); + /** + * An {@link LlmBackend} that names the model it will answer with. A backend that does not implement this names none. + * + *

+ * A separate interface rather than a default on {@link LlmBackend}, so a backend built before it cannot have an unrelated method of the same name taken for this one. + */ + interface ActiveModelReportingBackend extends LlmBackend { + + /** + * Gets the name of the model this backend will answer with, for showing to the user -- for example {@code "gemini-2.5-flash"}, or a local model's file name. + * + *

+ * Must be cheap and non-blocking: answer from state the backend already holds, never by asking a server. Call {@link LlmInferenceService#notifyBackendChanged} when it changes. + * + *

+ * Named for the active model rather than {@code getModelName} because shipped backends already declare a private {@code getModelName()}, which a method of that name would clash with. + * + * @return the model name, or null when the backend has no model selected + */ + @Nullable + String getActiveModelName(); + } + + /** + * Told when the backends change. Implemented by a consumer, not by a backend. + * + *

+ * The service calls it synchronously on whichever thread made the change, never while holding its own lock, so the listener may call back into the service. It must return promptly and hop to its own thread for anything slow, including UI work. An exception it throws is caught and does not reach the backend or the other listeners. + */ + interface BackendChangeListener { + + /** + * Called after a backend registered, unregistered, signalled a change, or became the user's selection. Re-read {@link LlmInferenceService#getBackend}; when the backend has unregistered, it returns null. + * + * @param backendId + * the {@link LlmBackend#getId} that changed (never null) + */ + void onBackendChanged(@NonNull String backendId); + } + /** * A backend whose in-flight streaming generation can be cancelled (Stop pressed). */ @@ -315,7 +388,7 @@ static List requireValidBatch(List texts) { * Gets the number of components in every vector this backend produces. * *

- * Constant for the lifetime of the backend instance, so a consumer may size its storage once. It is not on its own an identity: two unrelated models commonly share a width, and comparing their vectors yields a similarity that looks ordinary and means nothing. Pair it with {@link #getEmbeddingModelId} wherever provenance is recorded. + * Constant while {@link #getEmbeddingModelId} is unchanged, so a consumer may size its storage once per model; only an {@link EmbeddingModelSelectable} backend changes model. It is not on its own an identity: two unrelated models commonly share a width, and comparing their vectors yields a similarity that looks ordinary and means nothing. Pair it with {@link #getEmbeddingModelId} wherever provenance is recorded. * * @return the vector length, always positive */ @@ -333,6 +406,44 @@ static List requireValidBatch(List texts) { String getEmbeddingModelId(); } + /** + * An {@link EmbeddingBackend} whose embedding model a consumer may list and change, so a screen outside the backend's plugin can offer the choice. + * + *

+ * The backend still owns where the choice is stored; the consumer only reads the options and names one. Implementing this is the declaration, and the consumer asks with {@code instanceof} before it draws a picker. + * + *

+ * Changing the model. Once {@link #setEmbeddingModelId} returns, {@link #getEmbeddingModelId} and {@link #getEmbeddingDimensions} describe the new model, and the backend calls {@link LlmInferenceService#notifyBackendChanged}. A consumer that stores vectors compares the model id it recorded and reindexes when it differs. + */ + interface EmbeddingModelSelectable extends EmbeddingBackend { + + /** + * Lists the embedding models this backend can use right now, for example the ones the user's API key is allowed to call. + * + *

+ * May ask a server, so it must not block the calling thread: start the work and return the future. A failure -- no key, no network -- completes the future exceptionally. + * + * @return a future yielding the model identifiers, in display order, possibly empty (never null) + */ + @NonNull + CompletableFuture> listEmbeddingModels(); + + /** + * Makes {@code modelId} the model {@link #embed} uses, and stores the choice where the backend keeps its own settings. + * + *

+ * Must be cheap and non-blocking, because a consumer calls it from the UI thread. Setting the current model again changes nothing and need not notify. + * + * @param modelId + * one of the identifiers {@link #listEmbeddingModels} returned (must not be null or empty) + * @throws IllegalArgumentException + * if {@code modelId} is empty + * @throws NullPointerException + * if {@code modelId} is null + */ + void setEmbeddingModelId(@NonNull String modelId); + } + /** * An {@link LlmBackend} that renders earlier turns of a conversation. * @@ -559,6 +670,34 @@ public LlmResponse(boolean success, @Nullable String text, @Nullable String erro } } + /** + * An {@link LlmBackend} that knows whether it can answer right now, which {@link LlmBackend#isAvailable} does not say. A backend that does not implement this is read as {@link CapabilityStatus#AVAILABLE}. + * + *

+ * A separate interface rather than defaults on {@link LlmBackend}, so a backend built before it cannot have an unrelated method of the same name taken for these. + */ + interface StatusReportingBackend extends LlmBackend { + + /** + * Gets whether this backend can answer right now. Only meaningful while {@link #isAvailable} is true; a backend that is not set up is not asked. + * + *

+ * Must be cheap and non-blocking: answer from state the backend already holds, never by probing a server. Consumers call it on the UI thread; learning the state is the backend's own background work, and {@link LlmInferenceService#notifyBackendChanged} is how it reports a change. + * + * @return the status (never null) + */ + @NonNull + CapabilityStatus getStatus(); + + /** + * Gets one user-facing sentence explaining a {@link #getStatus} other than {@link CapabilityStatus#AVAILABLE}, for example {@code "Couldn't reach http://localhost:1234/v1"}. Same cheap, non-blocking rule as {@link #getStatus}. + * + * @return the reason, or null when the status is {@link CapabilityStatus#AVAILABLE} or there is nothing to add + */ + @Nullable + String getStatusMessage(); + } + /** * Callback for streaming responses */ diff --git a/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/ToolSourceRegistry.java b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/ToolSourceRegistry.java index f98a746c1c..60c6db1768 100644 --- a/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/ToolSourceRegistry.java +++ b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/ToolSourceRegistry.java @@ -17,7 +17,7 @@ * Values crossing this boundary must be JDK types ({@code String}, {@code Boolean}, {@code Integer}, {@code Double}, {@code List}, {@code Map}). Each plugin is loaded by its own class loader with the host as parent, so a class packaged in one {@code .cgp} is not resolvable from another; only types loaded by the host -- this interface and the JDK -- are common ground. A type duplicated into each plugin instead compiles cleanly and then fails on device with {@code ClassCastException}, because each loader defines its own copy. * *

- * Every member here is an interface rather than a value class on purpose: {@code plugin-api} is additive-only, and adding a property to a class removes the constructor signature already-published plugins were built against. Java {@code default} methods let this contract grow without touching an implementor. The cost is a small concrete class on each side. + * Every member here is an interface rather than a value class on purpose: {@code plugin-api} is additive-only, and adding a property to a class removes the constructor signature already-published plugins were built against. Java {@code default} methods let this contract grow without touching an implementor. The cost is a small concrete class on each side. New optional behaviour for a {@link ToolSource} comes instead as an interface extending it -- {@link StatusReportingToolSource}, {@link GroupedToolSource} -- because a provider built earlier may already declare an unrelated method a default of that name would be taken for. */ public interface ToolSourceRegistry { @@ -27,7 +27,18 @@ public interface ToolSourceRegistry { *

* It marks the revision, it does not negotiate one: javac inlines a constant into every class that reads it, so a plugin carries the value it compiled against and the host its own, and neither can read the other's. Version compatibility is enforced where the loader already enforces it, by {@code plugin.min_ide_version} in the plugin manifest. */ - int CONTRACT_VERSION = 1; + int CONTRACT_VERSION = 2; + + /** + * Adds a listener told whenever what {@link #getToolSources} describes changes: a source registers or unregisters, or a provider calls {@link #notifyToolsChanged} or {@link #notifyToolSourceStatusChanged}. Adding a listener already added has no effect. + * + *

+ * Defaults to doing nothing, which is what an agent plugin built before contract 2 provides: its consumers are never notified, and must read {@link #getToolSources} when they need it. + * + * @param listener + * the listener to add (must not be null) + */ + default void addToolSourceListener(@NonNull ToolSourceListener listener) {} /** * Gets every registered source, in registration order. @@ -38,13 +49,24 @@ public interface ToolSourceRegistry { List getToolSources(); /** - * Signals that a provider's tool list has changed and must be read again -- an MCP server connected, a user toggled a tool off. The agent re-reads {@link ToolSource#listTools} and rebuilds whatever it derives from it. + * Signals that a provider's tool list has changed and must be read again -- an MCP server connected, a user toggled a tool off. The agent re-reads {@link ToolSource#listTools} and {@link GroupedToolSource#getToolGroups} and rebuilds whatever it derives from them. * * @param providerId * the {@link ToolSource#getProviderId} whose tools changed; unknown ids are ignored */ void notifyToolsChanged(@NonNull String providerId); + /** + * Signals that a provider's health has changed and must be read again -- an MCP server went unreachable, or came back. Consumers re-read {@link StatusReportingToolSource#getStatus} and the status of each {@link GroupedToolSource#getToolGroups}; the tool list is not re-read, and listeners hear {@link ToolSourceListener#onToolSourceStatusChanged}. + * + *

+ * Defaults to doing nothing, which is what an agent plugin built before contract 2 provides. + * + * @param providerId + * the {@link ToolSource#getProviderId} whose status changed; unknown ids are ignored + */ + default void notifyToolSourceStatusChanged(@NonNull String providerId) {} + /** * Adds a source's tools to the agent, replacing any source already registered under the same {@link ToolSource#getProviderId}. Re-registration is how a provider recovers after the agent plugin restarts. * @@ -53,6 +75,17 @@ public interface ToolSourceRegistry { */ void registerToolSource(@NonNull ToolSource source); + /** + * Removes a listener previously passed to {@link #addToolSourceListener}; it is not called again once this returns. A listener that is not added is ignored. + * + *

+ * Defaults to doing nothing, matching {@link #addToolSourceListener}. + * + * @param listener + * the listener to remove (must not be null) + */ + default void removeToolSourceListener(@NonNull ToolSourceListener listener) {} + /** * Removes a source previously passed to {@link #registerToolSource}, matched by instance identity rather than by id, so a provider id a second plugin happens to reuse does not remove the first plugin's source. * @@ -64,6 +97,104 @@ public interface ToolSourceRegistry { */ void unregisterToolSource(@NonNull ToolSource source); + /** + * A {@link ToolSource} that divides its tools into groups -- one per MCP server, say -- so a consumer can present each separately. A source that does not implement this is one unit, presented by {@link ToolSource#getDisplayName}. + */ + interface GroupedToolSource extends ToolSource { + + /** + * Gets the groups this source divides its tools into. Read with {@link #listTools}, on registration and after {@link ToolSourceRegistry#notifyToolsChanged}, and again after {@link ToolSourceRegistry#notifyToolSourceStatusChanged}, so a group's status may be a snapshot; must be cheap and must not block on the network. + * + * @return the groups, in presentation order (never null) + */ + @NonNull + List getToolGroups(); + } + + /** + * A {@link ToolSource} that reports its health. A source that does not implement this is read as {@link CapabilityStatus#AVAILABLE}. + */ + interface StatusReportingToolSource extends ToolSource { + + /** + * Gets this source's health as a whole. For a {@link GroupedToolSource}, each {@link ToolGroup#getStatus} is the finer answer; this one summarises them. + * + *

+ * Must be cheap and non-blocking: answer from state the source already holds, never by probing a server or waiting on I/O. Consumers call it on the UI thread every time they redraw; learning the state is the source's own background work, and {@link ToolSourceRegistry#notifyToolSourceStatusChanged} is how it reports a change. + * + * @return the status (never null) + */ + @NonNull + CapabilityStatus getStatus(); + + /** + * Gets one user-facing sentence explaining a {@link #getStatus} other than {@link CapabilityStatus#AVAILABLE}. Same cheap, non-blocking rule as {@link #getStatus}. + * + * @return the reason, or null when the status is {@link CapabilityStatus#AVAILABLE} or there is nothing to add + */ + @Nullable + String getStatusMessage(); + } + + /** + * A named subset of one {@link GroupedToolSource}'s tools, presented on its own -- for example the tools of one MCP server. + * + *

+ * A group may name no tools: a server that is configured but unreachable offers none, and keeping its group with a {@link CapabilityStatus#DEGRADED} status is how it stays visible instead of disappearing. + */ + interface ToolGroup { + + /** + * Gets the human-readable group name, for example the server's name. + * + * @return the display name (never null) + */ + @NonNull + String getDisplayName(); + + /** + * Gets this group's stable identity, unique within its source. + * + * @return the group identifier (never null) + */ + @NonNull + String getId(); + + /** + * Gets this group's health. Same cheap, non-blocking rule as {@link StatusReportingToolSource#getStatus}. + * + *

+ * Defaults to {@link CapabilityStatus#AVAILABLE}. + * + * @return the status (never null) + */ + @NonNull + default CapabilityStatus getStatus() { + return CapabilityStatus.AVAILABLE; + } + + /** + * Gets one user-facing sentence explaining a {@link #getStatus} other than {@link CapabilityStatus#AVAILABLE}. Same cheap, non-blocking rule as {@link StatusReportingToolSource#getStatus}. + * + *

+ * Defaults to null. + * + * @return the reason, or null when the status is {@link CapabilityStatus#AVAILABLE} or there is nothing to add + */ + @Nullable + default String getStatusMessage() { + return null; + } + + /** + * Gets the {@link ToolSpec#getName} of each tool in this group. Every name is one {@link ToolSource#listTools} also returns. + * + * @return the tool names (never null; empty when the group currently offers none) + */ + @NonNull + List getToolNames(); + } + /** * One call to a tool, constructed by the agent. */ @@ -195,6 +326,36 @@ default void cancel(@NonNull String callId) {} List listTools(); } + /** + * Told when the registered sources, their tools, or their status change; a status change has its own callback, so a listener need not re-read the tool list for it. Implemented by a consumer -- the agent's own UI, say -- not by a tool provider. + * + *

+ * The registry calls it synchronously on whichever thread made the change, never while holding its own lock, so the listener may call back into the registry. It must return promptly and hop to its own thread for anything slow, including UI work. An exception it throws is caught and does not reach the provider or the other listeners. + */ + interface ToolSourceListener { + + /** + * Called after a source registered, unregistered, or called {@link ToolSourceRegistry#notifyToolsChanged}. Re-read {@link ToolSourceRegistry#getToolSources}; when the provider has unregistered, its id is no longer among them. + * + * @param providerId + * the {@link ToolSource#getProviderId} that changed (never null) + */ + void onToolSourcesChanged(@NonNull String providerId); + + /** + * Called after a provider reported a health change through {@link ToolSourceRegistry#notifyToolSourceStatusChanged}. Its tools are unchanged: re-read only {@link StatusReportingToolSource#getStatus} and {@link GroupedToolSource#getToolGroups}, whose groups may be snapshots. + * + *

+ * Defaults to {@link #onToolSourcesChanged}, for a listener that re-reads everything either way. + * + * @param providerId + * the {@link ToolSource#getProviderId} whose status changed (never null) + */ + default void onToolSourceStatusChanged(@NonNull String providerId) { + onToolSourcesChanged(providerId); + } + } + /** * One tool a {@link ToolSource} offers. */ diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/LlmBackendRegistration.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/LlmBackendRegistration.kt new file mode 100644 index 0000000000..b069aeea8e --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/LlmBackendRegistration.kt @@ -0,0 +1,162 @@ +package com.itsaky.androidide.plugins.ai + +import android.content.SharedPreferences +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginLifecycleListener +import com.itsaky.androidide.plugins.services.LlmInferenceService +import com.itsaky.androidide.plugins.services.SharedServices + +/** + * Keeps one backend plugin's [LlmInferenceService.LlmBackend] registered with the router, and tells + * the router when the backend's settings change. + * + * Every backend plugin needs the same wiring, and the copies had begun to drift: plugins activate + * in parallel with no ordering, so the router may not exist yet when [start] runs, and it goes away + * and comes back whenever its provider is restarted. This listens for the provider's lifecycle and + * re-registers, and it watches the plugin's own settings file for the keys that change what the + * backend reports, so a consumer such as the agent's backend tag re-reads it. + * + * One instance per plugin. [start] and [stop] pair with the plugin's `activate` and `deactivate`; + * [stop] is idempotent, so calling it again from `dispose` is safe. + * + * @param context the backend plugin's own context + * @param preferences the backend's settings file, read when [start] runs + * @param watchedKeys the settings that change what the backend's `isAvailable`, model name or status + * answers. `clear()` sends no key to a host targeting SDK 28, so a backend that clears the file + * calls [notifyBackendChanged] itself. + * @param onSettingChanged called for every change to [preferences], after the router was told of a + * watched one, so a backend can react to a key of its own - re-checking a server, say + * @param onRegistered called after each successful registration, the first and every one following + * a restart of the router's provider + */ +class LlmBackendRegistration( + private val context: PluginContext, + private val preferences: () -> SharedPreferences, + private val watchedKeys: Set, + private val onSettingChanged: (key: String?) -> Unit = {}, + private val onRegistered: () -> Unit = {}, +) { + companion object { + /** The plugin that publishes [LlmInferenceService]. */ + const val AI_CORE_PLUGIN_ID = "com.itsaky.androidide.plugins.aicore" + } + + private val lock = Any() + + @Volatile + private var backend: LlmInferenceService.LlmBackend? = null + + /** Whether the backend is registered with the router now. */ + @Volatile + var isRegistered: Boolean = false + private set + + /** Re-registers when the router's provider activates; its deactivation took the registration with it. */ + private val providerLifecycle = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) { + if (pluginId == AI_CORE_PLUGIN_ID) register() + } + + override fun onPluginDeactivated(pluginId: String) { + if (pluginId == AI_CORE_PLUGIN_ID) synchronized(lock) { isRegistered = false } + } + + override fun onPluginUninstalled(pluginId: String) { + if (pluginId == AI_CORE_PLUGIN_ID) synchronized(lock) { isRegistered = false } + } + } + + /** A field, not a lambda at the call: SharedPreferences holds its listeners weakly. */ + private val settingsWatch = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key in watchedKeys) notifyBackendChanged() + onSettingChanged(key) + } + + /** + * Starts keeping [backend] registered: watches the settings and the router's provider, then + * registers if the router is already there. Replaces a backend from an earlier [start]. + * + * @param backend the backend this plugin contributes + * @return true when it is registered now, false when it will be once the router appears + */ + fun start(backend: LlmInferenceService.LlmBackend): Boolean { + stop() + this.backend = backend + preferences().registerOnSharedPreferenceChangeListener(settingsWatch) + // Listen first, then try: a listener added after a successful attempt would still be needed + // for a later restart of the provider, and one added before costs nothing. + context.addPluginLifecycleListener(providerLifecycle) + val registered = register() + if (!registered) { + context.logger.info("LlmBackendRegistration: router not active yet; will register '${backend.id}' when it activates") + } + return registered + } + + /** + * Unregisters the backend and stops watching. Does not close the backend, which stays the + * plugin's to release. Idempotent. + */ + fun stop() { + runCatching { context.removePluginLifecycleListener(providerLifecycle) } + runCatching { preferences().unregisterOnSharedPreferenceChangeListener(settingsWatch) } + synchronized(lock) { + val current = backend ?: return + backend = null + if (!isRegistered) return + isRegistered = false + try { + resolveService()?.unregisterBackend(current.id) + context.logger.info("LlmBackendRegistration: unregistered '${current.id}'") + } catch (e: Exception) { + context.logger.error("LlmBackendRegistration: could not unregister '${current.id}'", e) + } + } + } + + /** Tells the router the backend's availability, model or status changed, if it is registered. */ + fun notifyBackendChanged() { + val current = backend ?: return + if (!isRegistered) return + try { + resolveService()?.notifyBackendChanged(current.id) + } catch (e: Exception) { + context.logger.debug("LlmBackendRegistration: could not report a change to '${current.id}': ${e.message}") + } + } + + /** @return true when the backend is registered, now or already */ + private fun register(): Boolean { + synchronized(lock) { + if (isRegistered) return true + val current = backend ?: return false + val service = resolveService() ?: return false + try { + service.registerBackend(current) + } catch (e: Exception) { + context.logger.error("LlmBackendRegistration: could not register '${current.id}'", e) + return false + } + isRegistered = true + context.logger.info("LlmBackendRegistration: registered '${current.id}'") + } + // Outside the lock: the callback may call back into notifyBackendChanged. + onRegistered() + return true + } + + /** + * The 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 resolveService(): LlmInferenceService? = + try { + SharedServices.get(LlmInferenceService::class.java) + ?: context.getPluginService(AI_CORE_PLUGIN_ID, LlmInferenceService::class.java) + } catch (e: Exception) { + context.logger.warn("LlmBackendRegistration: could not resolve LlmInferenceService: ${e.message}") + null + } +} diff --git a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/LlmInferenceServiceTest.java b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/LlmInferenceServiceTest.java index 4ff3f6b695..cecf32bdc5 100644 --- a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/LlmInferenceServiceTest.java +++ b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/LlmInferenceServiceTest.java @@ -183,6 +183,20 @@ public void llmResponseSuccessCarriesTextAndNoError() { assertEquals(340L, response.timeMs); } + @Test + public void serviceBuiltBeforeListenersAcceptsThemAndNeverCalls() { + LlmInferenceService service = new ListenerlessService(); + LlmInferenceService.BackendChangeListener listener = backendId -> { + throw new AssertionError("a service without listener support must not call one"); + }; + + service.addBackendChangeListener(listener); + service.registerBackend(new PlainBackend()); + service.notifyBackendChanged("plain"); + service.unregisterBackend("plain"); + service.removeBackendChangeListener(listener); + } + @Test public void systemPromptRequestAcceptsNoCallSyntax() { LlmInferenceService.SystemPromptRequest request = new LlmInferenceService.SystemPromptRequest(Collections.emptyList(), null, null); @@ -286,6 +300,57 @@ public CompletableFuture> embed(List texts) { } } + /** + * A service as ai-core built it before backend-change listeners existed: it implements only what was abstract then. + */ + private static final class ListenerlessService implements LlmInferenceService { + + @Override + public void cancelGeneration() {} + + @Override + public CompletableFuture generateCompletion(String prompt, LlmInferenceService.LlmConfig config) { + return CompletableFuture.completedFuture(LlmInferenceService.LlmResponse.failure("unused")); + } + + @Override + public void generateStreaming(String prompt, LlmInferenceService.LlmConfig config, LlmInferenceService.StreamCallback callback) {} + + @Override + public void generateStreamingWithTools(String prompt, List history, LlmInferenceService.LlmConfig config, List tools, LlmInferenceService.ToolStreamCallback callback) {} + + @Override + public CompletableFuture generateWithHistory(List history, String prompt, LlmInferenceService.LlmConfig config) { + return generateCompletion(prompt, config); + } + + @Override + public List getAvailableBackends() { + return Collections.emptyList(); + } + + @Override + public LlmInferenceService.LlmBackend getBackend(String backendId) { + return null; + } + + @Override + public CompletableFuture getEmbeddings(String text, String backendId) { + return CompletableFuture.completedFuture(new float[0]); + } + + @Override + public boolean isBackendAvailable(String backendId) { + return false; + } + + @Override + public void registerBackend(LlmInferenceService.LlmBackend backend) {} + + @Override + public void unregisterBackend(String backendId) {} + } + /** * An {@link LlmInferenceService.LlmBackend} with no embedding capability, so the {@code instanceof} discrimination is exercised against a backend that really lacks it rather than against a mock. */ diff --git a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/ToolSourceRegistryTest.java b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/ToolSourceRegistryTest.java index bd1d5214e2..b49f3ad0c6 100644 --- a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/ToolSourceRegistryTest.java +++ b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/ToolSourceRegistryTest.java @@ -1,9 +1,11 @@ package com.itsaky.androidide.plugins.services; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -15,6 +17,26 @@ */ public class ToolSourceRegistryTest { + @Test + public void registryBuiltBeforeListenersAcceptsThemAndNeverCalls() { + MinimalRegistry registry = new MinimalRegistry(); + ToolSourceRegistry.ToolSourceListener listener = providerId -> { + throw new AssertionError("a registry without listener support must not call one"); + }; + + registry.addToolSourceListener(listener); + registry.notifyToolSourceStatusChanged("com.example.tools"); + registry.removeToolSourceListener(listener); + } + + @Test + public void toolGroupIsAvailableWithNoReasonUntilItSaysOtherwise() { + ToolSourceRegistry.ToolGroup group = new MinimalGroup(); + + assertEquals(CapabilityStatus.AVAILABLE, group.getStatus()); + assertNull(group.getStatusMessage()); + } + @Test public void toolInvocationHasNoProjectRootUntilOneIsGiven() { ToolSourceRegistry.ToolInvocation invocation = new MinimalInvocation(); @@ -36,6 +58,16 @@ public void toolSourceIgnoresACancelItCannotHonour() { source.cancel("call-1"); } + @Test + public void toolSourceListenerHearsAStatusChangeAsAChangeUnlessItAsksToTellThemApart() { + List heard = new ArrayList<>(); + ToolSourceRegistry.ToolSourceListener listener = heard::add; + + listener.onToolSourceStatusChanged("com.example.tools"); + + assertEquals(Collections.singletonList("com.example.tools"), heard); + } + @Test public void toolSpecIsTreatedAsHavingSideEffectsUnlessASourceOptsIn() { ToolSourceRegistry.ToolSpec spec = new MinimalSpec(); @@ -58,6 +90,24 @@ public void toolSpecTakesNoTypedArgumentsUntilASchemaIsGiven() { } /** Implements only what the contract makes abstract, so every assertion above reads a default. */ + private static final class MinimalGroup implements ToolSourceRegistry.ToolGroup { + + @Override + public String getDisplayName() { + return "Example server"; + } + + @Override + public String getId() { + return "example-server"; + } + + @Override + public List getToolNames() { + return Collections.singletonList("list_files"); + } + } + private static final class MinimalInvocation implements ToolSourceRegistry.ToolInvocation { @Override @@ -89,6 +139,24 @@ public boolean isSuccess() { } } + /** A registry as ai-core built it before contract 2: it knows nothing of listeners or status. */ + private static final class MinimalRegistry implements ToolSourceRegistry { + + @Override + public List getToolSources() { + return Collections.emptyList(); + } + + @Override + public void notifyToolsChanged(String providerId) {} + + @Override + public void registerToolSource(ToolSourceRegistry.ToolSource source) {} + + @Override + public void unregisterToolSource(ToolSourceRegistry.ToolSource source) {} + } + private static final class MinimalSource implements ToolSourceRegistry.ToolSource { @Override diff --git a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/LlmBackendRegistrationTest.kt b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/LlmBackendRegistrationTest.kt new file mode 100644 index 0000000000..40619249ce --- /dev/null +++ b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/LlmBackendRegistrationTest.kt @@ -0,0 +1,145 @@ +package com.itsaky.androidide.plugins.ai + +import android.content.Context +import android.content.SharedPreferences +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginLifecycleListener +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.services.LlmInferenceService +import com.itsaky.androidide.plugins.services.SharedServices +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.lang.reflect.Proxy + +/** + * Robolectric for a real [SharedPreferences]: the settings watch is the half of the wiring that + * the backend tag depends on. The router and the context are proxies that record what was asked. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class LlmBackendRegistrationTest { + private lateinit var prefs: SharedPreferences + private val calls = mutableListOf() + private val lifecycle = mutableListOf() + private val changedKeys = mutableListOf() + private var registrations = 0 + + private val router = proxy { name, args -> calls += "$name:${args?.firstOrNull()?.let(::idOf)}" } + private val backend = proxy { name, _ -> if (name == "getId") ID else null } + private val logger = proxy { _, _ -> null } + private val context = + proxy { name, args -> + when (name) { + "getLogger" -> logger + "addPluginLifecycleListener" -> lifecycle += args!![0] as PluginLifecycleListener + "removePluginLifecycleListener" -> lifecycle -= args!![0] as PluginLifecycleListener + else -> null + } + } + + private lateinit var registration: LlmBackendRegistration + + @Before + fun setUp() { + prefs = RuntimeEnvironment.getApplication().getSharedPreferences("backend", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + SharedServices.unregister(LlmInferenceService::class.java) + registration = + LlmBackendRegistration( + context = context, + preferences = { prefs }, + watchedKeys = setOf(KEY_MODEL), + onSettingChanged = { changedKeys += it }, + onRegistered = { registrations++ }, + ) + } + + @After + fun tearDown() { + registration.stop() + SharedServices.unregister(LlmInferenceService::class.java) + } + + @Test + fun `Given_the_router_is_up_When_started_Then_the_backend_is_registered_once`() { + SharedServices.register(LlmInferenceService::class.java, router) + + assertThat(registration.start(backend)).isTrue() + + assertThat(calls).containsExactly("registerBackend:$ID") + assertThat(registrations).isEqualTo(1) + } + + @Test + fun `Given_no_router_yet_When_its_provider_activates_Then_the_backend_registers_then`() { + assertThat(registration.start(backend)).isFalse() + SharedServices.register(LlmInferenceService::class.java, router) + + lifecycle.toList().forEach { it.onPluginActivated(LlmBackendRegistration.AI_CORE_PLUGIN_ID) } + + assertThat(registration.isRegistered).isTrue() + assertThat(calls).containsExactly("registerBackend:$ID") + } + + @Test + fun `Given_a_registered_backend_When_the_provider_restarts_Then_it_registers_again`() { + SharedServices.register(LlmInferenceService::class.java, router) + registration.start(backend) + + lifecycle.toList().forEach { it.onPluginDeactivated(LlmBackendRegistration.AI_CORE_PLUGIN_ID) } + lifecycle.toList().forEach { it.onPluginActivated(LlmBackendRegistration.AI_CORE_PLUGIN_ID) } + + assertThat(calls).containsExactly("registerBackend:$ID", "registerBackend:$ID") + assertThat(registrations).isEqualTo(2) + } + + @Test + fun `Given_a_registered_backend_When_a_watched_key_changes_Then_the_router_is_told`() { + SharedServices.register(LlmInferenceService::class.java, router) + registration.start(backend) + + prefs.edit().putString(KEY_MODEL, "qwen3").commit() + prefs.edit().putString(KEY_OTHER, "x").commit() + + assertThat(calls).containsExactly("registerBackend:$ID", "notifyBackendChanged:$ID") + assertThat(changedKeys).containsExactly(KEY_MODEL, KEY_OTHER).inOrder() + } + + @Test + fun `Given_a_registered_backend_When_stopped_Then_it_unregisters_and_stops_listening`() { + SharedServices.register(LlmInferenceService::class.java, router) + registration.start(backend) + + registration.stop() + registration.stop() + prefs.edit().putString(KEY_MODEL, "qwen3").commit() + + assertThat(calls).containsExactly("registerBackend:$ID", "unregisterBackend:$ID") + assertThat(lifecycle).isEmpty() + assertThat(changedKeys).isEmpty() + } + + private fun idOf(arg: Any): Any = if (arg is LlmInferenceService.LlmBackend) arg.id else arg + + private companion object { + const val ID = "test-backend" + const val KEY_MODEL = "model" + const val KEY_OTHER = "unrelated" + + inline fun proxy(crossinline answer: (String, Array?) -> Any?): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { self, method, args -> + when (method.name) { + "hashCode" -> System.identityHashCode(self) + "equals" -> self === args?.get(0) + "toString" -> T::class.java.simpleName + else -> answer(method.name, args).takeUnless { it == Unit } + } + } as T + } +}