Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/PLUGIN_API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/plugin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
37 changes: 37 additions & 0 deletions plugin-api/api/plugin-api.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (Ljava/lang/String;)V
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* 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;
Comment thread
jatezzz marked this conversation as resolved.

/**
* Gets every registered source, in registration order.
*
* @return the registered sources (never null)
*/
@NonNull
List<ToolSource> 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.
*
* <p>
* 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);
Comment thread
jatezzz marked this conversation as resolved.

/**
* One call to a tool, constructed by the agent.
*/
interface ToolInvocation {

/**
* Gets the arguments, keyed by schema property name.
*
* <p>
* 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<String, Object> 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;
}
Comment thread
jatezzz marked this conversation as resolved.

/**
* 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.
*
* <p>
* 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.
*
* <p>
* 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.
*
* <p>
* 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<ToolOutcome> 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<ToolSpec> 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<String, Object> 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.
*
* <p>
* 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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Loading
Loading