diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 804a212381..1611f20b16 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -36,6 +36,28 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed] = diffed from `plugin-api/src` history (predates the dump; symbol-accurate). ### 26.33 — 2026-08-12 +- **added — Plugin-contributed agent tools** _(ADFA-2592)_ **[verified]** + Any `.cgp` can add tools to the AI agent, whose tool set was previously fixed at + ai-core compile time. The contract has to live in the host: each plugin is loaded + by its own class loader with the host as parent, so a type packaged in one `.cgp` + is not resolvable from another — and duplicating it into each plugin compiles + cleanly, then fails on device with `ClassCastException`. ai-core implements the + registry and publishes it under `SharedServices`, exactly as it does + `LlmInferenceService`; a provider registers on `activate()` and unregisters on + `deactivate()`. Host runtime behaviour, `PluginManager`, the loader and + `PluginPermission` are unchanged — a provider declares the permissions its own + work needs. + `ToolSourceRegistry` (`registerToolSource`, `unregisterToolSource`, + `getToolSources`, `notifyToolsChanged`, `CONTRACT_VERSION`), + `ToolSourceRegistry.ToolSource` / `.ToolSpec` / `.ToolInvocation` / `.ToolOutcome`. + Values crossing this boundary must be JDK types, and the registry hands each + source a sanitized copy of the argument map rather than its own. + `unregisterToolSource` takes the `ToolSource` instance, not a provider id, so a + reused provider id cannot remove another plugin's source — but the registry is + no trust boundary between plugins: `getToolSources` hands out the registered + instances and registering under a taken id replaces it. `ToolSpec.requiresApproval()` + defaults to **true**, inverted relative to the agent's own tools: those are + contained by its path guard, a contributed tool by nothing. - **added — Optional LLM backend capabilities** _(ADFA-5095)_ **[verified]** An LLM backend declares what it supports by the interfaces it implements, so a backend can ship as its own plugin and implement only what it can do. The diff --git a/docs/plugin-api.md b/docs/plugin-api.md index ed5ebee0ee..43c52cd0d1 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -12,7 +12,7 @@ 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`, `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`) and the value types either side constructs (`ChatMessage`, `LlmConfig`, `LlmResponse`, `SystemPromptRequest`, `ToolDefinition`, `ToolCallRequest`). + - 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`) 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. - 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`. - **Wire/format contracts outside the module:** diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index 24e2cb1765..af250a9c96 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -1667,6 +1667,43 @@ public abstract interface class com/itsaky/androidide/plugins/services/ThemeChan public abstract fun onThemeChanged (Z)V } +public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry { + public static final field CONTRACT_VERSION I + public abstract fun getToolSources ()Ljava/util/List; + public abstract fun notifyToolsChanged (Ljava/lang/String;)V + public abstract fun registerToolSource (Lcom/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSource;)V + public abstract fun unregisterToolSource (Lcom/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSource;)V +} + +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; + public fun getProjectRoot ()Ljava/lang/String; + public abstract fun getToolName ()Ljava/lang/String; +} + +public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolOutcome { + public fun getErrorMessage ()Ljava/lang/String; + public abstract fun getOutput ()Ljava/lang/String; + public abstract fun isSuccess ()Z +} + +public abstract interface class com/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolSource { + public fun cancel (Ljava/lang/String;)V + public abstract fun getDisplayName ()Ljava/lang/String; + public abstract fun getProviderId ()Ljava/lang/String; + public abstract fun invoke (Lcom/itsaky/androidide/plugins/services/ToolSourceRegistry$ToolInvocation;)Ljava/util/concurrent/CompletableFuture; + public abstract fun listTools ()Ljava/util/List; +} + +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; + public fun getParametersSchema ()Ljava/util/Map; + public fun isReadOnly ()Z + public fun requiresApproval ()Z +} + public final class com/itsaky/androidide/plugins/templates/CgtTemplateBuilder { public static final field Companion Lcom/itsaky/androidide/plugins/templates/CgtTemplateBuilder$Companion; public fun (Ljava/lang/String;)V 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 new file mode 100644 index 0000000000..f98a746c1c --- /dev/null +++ b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/ToolSourceRegistry.java @@ -0,0 +1,250 @@ +package com.itsaky.androidide.plugins.services; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Registry through which plugins contribute tools to the IDE's AI agent. + * + *

+ * The registry itself is implemented by the plugin that owns the agent (ai-core) and published under this type in {@link SharedServices}; the host defines the contract only. A contributing plugin resolves the registry on {@code activate()}, registers a {@link ToolSource}, and unregisters on {@code deactivate()} -- the same lifecycle a model backend follows with {@link LlmInferenceService#registerBackend}. When the agent plugin is not installed the lookup returns null and a provider registers nothing, which is the same clean degradation the backends already rely on. + * + *

+ * 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. + */ +public interface ToolSourceRegistry { + + /** + * Contract revision, bumped whenever a member is added here. + * + *

+ * 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; + + /** + * Gets every registered source, in registration order. + * + * @return the registered sources (never null) + */ + @NonNull + 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. + * + * @param providerId + * the {@link ToolSource#getProviderId} whose tools changed; unknown ids are ignored + */ + void notifyToolsChanged(@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. + * + * @param source + * the source to register (must not be null) + */ + void registerToolSource(@NonNull ToolSource source); + + /** + * 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. + * + *

+ * Identity is not proof of ownership and this is not a trust boundary between plugins: {@link #getToolSources} hands every caller the registered instances, and {@link #registerToolSource} replaces whatever is registered under the same id. What keeps a plugin out of the agent is not installing it. + * + * @param source + * the source to remove; a source that is not registered is ignored + */ + void unregisterToolSource(@NonNull ToolSource source); + + /** + * One call to a tool, constructed by the agent. + */ + interface ToolInvocation { + + /** + * Gets the arguments, keyed by schema property name. + * + *

+ * The registry implementation must hand each source a copy holding JDK value types only ({@code String}, {@code Boolean}, {@code Integer}, {@code Double}, {@code List}, {@code Map}), recursively -- a value of any other type is rejected or coerced before the call is dispatched, never passed through. Two obligations follow from the class loader split: an object defined by the agent's loader is not resolvable from a source's, and a map shared across the boundary would let either side mutate what the other reads. + * + * @return the arguments (never null; empty when the tool takes none) + */ + @NonNull + Map getArguments(); + + /** + * Gets the identifier of this call for the lifetime of the run; the key for {@link ToolSource#cancel}. + * + * @return the call identifier (never null) + */ + @NonNull + String getCallId(); + + /** + * Gets the absolute path of the open project's root. + * + * @return the project root, or null when no project is open + */ + @Nullable + default String getProjectRoot() { + return null; + } + + /** + * Gets the tool's own {@link ToolSpec#getName}, without the agent's namespace prefix. + * + * @return the tool name (never null) + */ + @NonNull + String getToolName(); + } + + /** + * The result of one call. + * + *

+ * A failing outcome must say why. When {@link #isSuccess} returns false, at least one of {@link #getErrorMessage} and {@link #getOutput} has to carry the detail -- the message for the user, the output for the model. Both is better; neither leaves the model with an unexplained refusal, which it retries. + */ + interface ToolOutcome { + + /** + * Gets one user-facing sentence explaining a failure. + * + * @return the error message, or null when {@link #isSuccess} is true or the failure is already explained by {@link #getOutput} + */ + @Nullable + default String getErrorMessage() { + return null; + } + + /** + * Gets the result as text for the model. The agent truncates it, so put the answer first. + * + * @return the output (never null) + */ + @NonNull + String getOutput(); + + /** + * Checks whether the tool did what was asked. A false outcome is reported to the model. + * + * @return true if the call succeeded, false otherwise + */ + boolean isSuccess(); + } + + /** + * A plugin's contribution of one or more agent tools. + * + *

+ * Implementations must not throw across this boundary: the agent treats a throwing source as absent, so a failing {@code .cgp} costs the user its tools rather than the whole agent. + */ + interface ToolSource { + + /** + * Best-effort cancellation of an in-flight {@link #invoke}, matched by {@link ToolInvocation#getCallId}. Called when the user stops the agent run. + * + *

+ * Best-effort covers how much work is undone, not whether the future settles: the {@link CompletableFuture} that {@code invoke} returned must still reach a terminal state. Complete it exceptionally with a {@link java.util.concurrent.CancellationException} once the work stops, or normally if it had already finished when the cancel arrived. A future left pending strands the agent's continuation until its own timeout fires. + * + * @param callId + * the call to cancel; unknown ids are ignored + */ + default void cancel(@NonNull String callId) {} + + /** + * Gets the human-readable source name, shown wherever tool provenance is surfaced. + * + * @return the display name (never null) + */ + @NonNull + String getDisplayName(); + + /** + * Gets this source's stable identity, conventionally the contributing plugin's {@code plugin.id}. + * + * @return the provider identifier (never null) + */ + @NonNull + String getProviderId(); + + /** + * Runs one tool. Must return promptly and complete the future off the caller's thread; the agent awaits it and never blocks the UI thread on it. + * + * @param invocation + * the call to run (must not be null) + * @return a future that completes with the outcome (never null) + */ + @NonNull + CompletableFuture invoke(@NonNull ToolInvocation invocation); + + /** + * Gets the tools currently offered. Called on registration and after {@link ToolSourceRegistry#notifyToolsChanged}; must be cheap and must not block on the network. + * + * @return the tools this source offers (never null) + */ + @NonNull + List listTools(); + } + + /** + * One tool a {@link ToolSource} offers. + */ + interface ToolSpec { + + /** + * Gets what the tool does, in one or two sentences -- this reaches the model's prompt. + * + * @return the description (never null) + */ + @NonNull + String getDescription(); + + /** + * Gets this tool's name, unique within its source. The agent namespaces it before exposing it to the model. + * + * @return the tool name (never null) + */ + @NonNull + String getName(); + + /** + * Gets the JSON schema for the arguments: a JSON Schema object -- {@code "type": "object"} with {@code "properties"} and {@code "required"} -- expressed in the JDK value types {@link ToolInvocation#getArguments} accepts, so it needs no conversion on the way to a model. + * + * @return the parameter schema; empty means untyped, flat string arguments, which is what the current tool-call protocol supports + */ + @NonNull + default Map getParametersSchema() { + return Collections.emptyMap(); + } + + /** + * Checks whether the tool is free of side effects, allowing the agent to run it concurrently. + * + * @return true if the tool only reads, false otherwise + */ + default boolean isReadOnly() { + return false; + } + + /** + * Checks whether the user must approve each call. + * + *

+ * Defaults to true, inverted relative to the agent's own tools: those are contained by the agent's path guard before a handler runs, while a tool contributed by a third party -- or proxied from a remote server -- is contained by nothing. The safe default is to ask. + * + * @return true if each call needs user approval, false otherwise + */ + default boolean requiresApproval() { + return true; + } + } +} 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 new file mode 100644 index 0000000000..bd1d5214e2 --- /dev/null +++ b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/ToolSourceRegistryTest.java @@ -0,0 +1,127 @@ +package com.itsaky.androidide.plugins.services; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.Test; + +/** + * Pins the {@code default} methods of the contributed-tool contract. Every implementor is an out-of-tree plugin, so a default that changes here changes behaviour in plugins nothing in this repo compiles against -- {@link ToolSourceRegistry.ToolSpec#requiresApproval} most of all, since silently flipping it to false would run third-party tools without asking the user. + */ +public class ToolSourceRegistryTest { + + @Test + public void toolInvocationHasNoProjectRootUntilOneIsGiven() { + ToolSourceRegistry.ToolInvocation invocation = new MinimalInvocation(); + + assertNull(invocation.getProjectRoot()); + } + + @Test + public void toolOutcomeCarriesNoErrorMessageUntilOneIsGiven() { + ToolSourceRegistry.ToolOutcome outcome = new MinimalOutcome(); + + assertNull(outcome.getErrorMessage()); + } + + @Test + public void toolSourceIgnoresACancelItCannotHonour() { + ToolSourceRegistry.ToolSource source = new MinimalSource(); + + source.cancel("call-1"); + } + + @Test + public void toolSpecIsTreatedAsHavingSideEffectsUnlessASourceOptsIn() { + ToolSourceRegistry.ToolSpec spec = new MinimalSpec(); + + assertFalse(spec.isReadOnly()); + } + + @Test + public void toolSpecRequiresApprovalUnlessASourceOptsOut() { + ToolSourceRegistry.ToolSpec spec = new MinimalSpec(); + + assertTrue(spec.requiresApproval()); + } + + @Test + public void toolSpecTakesNoTypedArgumentsUntilASchemaIsGiven() { + ToolSourceRegistry.ToolSpec spec = new MinimalSpec(); + + assertTrue(spec.getParametersSchema().isEmpty()); + } + + /** Implements only what the contract makes abstract, so every assertion above reads a default. */ + private static final class MinimalInvocation implements ToolSourceRegistry.ToolInvocation { + + @Override + public Map getArguments() { + return Collections.emptyMap(); + } + + @Override + public String getCallId() { + return "call-1"; + } + + @Override + public String getToolName() { + return "list_files"; + } + } + + private static final class MinimalOutcome implements ToolSourceRegistry.ToolOutcome { + + @Override + public String getOutput() { + return "done"; + } + + @Override + public boolean isSuccess() { + return true; + } + } + + private static final class MinimalSource implements ToolSourceRegistry.ToolSource { + + @Override + public String getDisplayName() { + return "Example tools"; + } + + @Override + public String getProviderId() { + return "com.example.tools"; + } + + @Override + public CompletableFuture invoke(ToolSourceRegistry.ToolInvocation invocation) { + return CompletableFuture.completedFuture(new MinimalOutcome()); + } + + @Override + public List listTools() { + return Collections.singletonList(new MinimalSpec()); + } + } + + private static final class MinimalSpec implements ToolSourceRegistry.ToolSpec { + + @Override + public String getDescription() { + return "Lists files"; + } + + @Override + public String getName() { + return "list_files"; + } + } +}