From 36898d27873d0665f1d2160ad82a732933c2083a Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 30 Sep 2026 09:50:21 -0500 Subject: [PATCH 1/6] feat(plugin-api): ADFA-6281 share AI prompt engine and pane helpers Host-side template engine, generic YAML config loader/store and UI helpers, consumed by AI-Core and the Gemini, Local, OpenAI and MCP agents in plugin-examples. ABI-additive. Prompt config: - Include paths refuse a backslash and ':', which a Windows host would read as a separator or a drive. - Any IOException from a source is reported as a PromptConfigException naming the file. - Duplicate keys are refused explicitly; already SnakeYAML Engine's default, stated so an upgrade cannot flip it. - Config text keeps its whitespace as YAML parsed it, so `|` keeps its final newline and `|-` drops it. - PromptConfigStore treats a load that completed with an exception as failed: preload() retries it and configIfLoaded() returns null rather than calling getCompleted(). Template engine: - Names may be in any case and hold dots ({{fileName}}, {{item.name}}), looked up as the whole key. Pane helpers: - SecretRevealController keeps the user's selection across a toggle, does not report legibility from attach(), is @MainThread, and waits for the redraw via postOnAnimation. - applyPaneStyling does not recurse into a styled control's children. --- app/proguard-rules.pro | 1 + docs/PLUGIN_API_CHANGELOG.md | 29 +++ docs/plugin-api.md | 2 +- gradle/libs.versions.toml | 2 + plugin-api/api/plugin-api.api | 178 +++++++++++++ plugin-api/build.gradle.kts | 4 + .../plugins/ai/prompt/PromptConfigDocument.kt | 93 +++++++ .../ai/prompt/PromptConfigException.kt | 7 + .../plugins/ai/prompt/PromptConfigLoader.kt | 107 ++++++++ .../plugins/ai/prompt/PromptConfigObject.kt | 121 +++++++++ .../plugins/ai/prompt/PromptConfigSource.kt | 40 +++ .../plugins/ai/prompt/PromptConfigStore.kt | 95 +++++++ .../plugins/ai/prompt/PromptTemplateEngine.kt | 240 ++++++++++++++++++ .../plugins/ai/prompt/PromptText.kt | 13 + .../androidide/plugins/ai/ui/PaneStyling.kt | 129 ++++++++++ .../plugins/ai/ui/SecretRevealController.kt | 114 +++++++++ .../plugins/ai/prompt/PromptConfigFixture.kt | 123 +++++++++ .../ai/prompt/PromptConfigLoaderTest.kt | 166 ++++++++++++ .../ai/prompt/PromptConfigObjectTest.kt | 164 ++++++++++++ .../ai/prompt/PromptConfigStoreTest.kt | 139 ++++++++++ .../ai/prompt/PromptTemplateEngineTest.kt | 229 +++++++++++++++++ 21 files changed, 1995 insertions(+), 1 deletion(-) create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigDocument.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigException.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigLoader.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigObject.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigSource.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptText.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/PaneStyling.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/SecretRevealController.kt create mode 100644 plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigFixture.kt create mode 100644 plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigLoaderTest.kt create mode 100644 plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigObjectTest.kt create mode 100644 plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStoreTest.kt create mode 100644 plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngineTest.kt diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ea7468a448..d47e27c123 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -256,6 +256,7 @@ -keep class com.google.android.material.** { *; } -keep class io.noties.markwon.** { *; } -keep class com.google.gson.** { *; } +-keep class org.snakeyaml.engine.** { *; } -keep class com.google.firebase.** { *; } -keep class com.google.android.gms.** { *; } diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index b78fe6ab03..de251496f2 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -36,6 +36,35 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed] = diffed from `plugin-api/src` history (predates the dump; symbol-accurate). ### 26.40 — unreleased +- **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 + styling, so a fix had to be repeated per plugin and a missed copy made the plugins + drift. The host now ships one copy. + `com.itsaky.androidide.plugins.ai.prompt`: `PromptTemplateEngine` and `PromptText` + (the `{{NAME}}` / `{{#NAME}}` / `{{^NAME}}` renderer; names may be in any case and hold + dots, e.g. `{{fileName}}` or `{{item.name}}`, and config text keeps its whitespace as YAML + parsed it); `PromptConfigLoader.load(source, + parser)`, which reads `agent.yml` and its `include` list off the main thread; + `PromptConfigDocument` and `PromptConfigObject`, the strict key-by-key reader a parser + maps the merged YAML through; `PromptConfigSource` / `AssetPromptConfigSource`; + `PromptConfigException`; and `PromptConfigStore`, the per-activation cache, behind + `PromptConfigProvider`. Loader and store are generic over the plugin's config type: + a plugin supplies only a `PromptConfigParser` and keeps one store, e.g. + `val shared = PromptConfigStore(MyParser)`. The YAML library (snakeyaml-engine 2.10) + is on the host side, so a plugin using the loader no longer bundles it. + `com.itsaky.androidide.plugins.ai.ui`: `SecretRevealController`, whose two states are + each a `RevealToggle` (icon and content description), and + `View.applyPaneStyling(PaneStyle, outlinedButtonIds)`, with `PaneStyle` grouping a + `ButtonColors` per emphasis and a `FieldColors`. Both take the plugin's own resource ids + rather than shipping any: they resolve against the view's context, which carries the + plugin's resources, not the host's. + Additive to the ABI (178 added lines in the dump, none removed), but no longer unused: + AI-Core and the Gemini, Local and OpenAI agents now load and render their prompt config + through `ai.prompt` and drop their private copies, and the Gemini, OpenAI and MCP + settings screens use `ai.ui`. Floor + `plugin.min_ide_version` at `26.40` to use any of it; an older IDE has none of these + classes, and the plugin fails with `NoClassDefFoundError` on first use. - **added — Read-only App Logs and IDE Logs** _(ADFA-6267)_ **[verified]** Plugins could read build output (`IdeBuildService.getBuildOutput()`) but not the App Logs or IDE Logs tabs, so an agent diagnosing a runtime crash had to ask the user to paste them. diff --git a/docs/plugin-api.md b/docs/plugin-api.md index f8a52af5c1..3d8aae5450 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -13,7 +13,7 @@ The surface a plugin binds to is broader than one module. All of the following a - 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). Host-side implementation rather than an interface, so plugins share one copy in the process instead of compiling their own. + - 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. - 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:** diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a0d5ac5e88..bcec951b43 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -299,6 +299,8 @@ tooling-agp = { module = "com.android.tools.build:gradle", version.ref = "agp-to tooling-builderModel = { module = "com.android.tools.build:builder-model", version.ref = "agp-tooling" } tooling-gradleApi = { module = "com.itsaky.androidide.gradle:gradle-tooling-api", version.ref = "gradle-tooling" } tooling-slf4j = { module = "org.slf4j:slf4j-api", version = "2.0.12" } +# 2.x targets Java 8; 3.x needs 11. Match the AI plugins' copy, which the app shadows parent-first. +snakeyaml-engine = { module = "org.snakeyaml:snakeyaml-engine", version = "2.10" } # Classpaths android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index 031149a7db..c4c96c3b0b 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -137,6 +137,184 @@ 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/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; + public fun (Landroid/content/res/AssetManager;)V + public fun (Landroid/content/res/AssetManager;Ljava/lang/String;)V + public synthetic fun (Landroid/content/res/AssetManager;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun read (Ljava/lang/String;)Ljava/lang/String; +} + +public final class com/itsaky/androidide/plugins/ai/prompt/AssetPromptConfigSource$Companion { +} + +public final class com/itsaky/androidide/plugins/ai/prompt/PromptConfigDocument { + public static final field Companion Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigDocument$Companion; + public synthetic fun (Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun fileOf (Ljava/lang/String;)Ljava/lang/String; + public final fun getEntryFile ()Ljava/lang/String; + public final fun getValues ()Ljava/util/Map; + public final fun read (Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; +} + +public final class com/itsaky/androidide/plugins/ai/prompt/PromptConfigDocument$Companion { + public final fun merge (Ljava/lang/String;Ljava/util/List;)Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigDocument; +} + +public final class com/itsaky/androidide/plugins/ai/prompt/PromptConfigException : java/lang/IllegalArgumentException { + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class com/itsaky/androidide/plugins/ai/prompt/PromptConfigLoader { + public static final field FILE Ljava/lang/String; + public static final field INSTANCE Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigLoader; + public final fun load (Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigSource;Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigParser;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun load$default (Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigLoader;Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigSource;Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigParser;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class com/itsaky/androidide/plugins/ai/prompt/PromptConfigObject { + public final fun int (Ljava/lang/String;)I + public final fun invalid (Ljava/lang/String;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigException; + public final fun labelOf (Ljava/lang/String;)Ljava/lang/String; + public final fun obj (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigObject; + public final fun objectEntries (Ljava/lang/String;)Ljava/util/Map; + public final fun objects (Ljava/lang/String;)Ljava/util/List; + public final fun optionalTextEntries (Ljava/lang/String;)Ljava/util/Map; + public final fun read (Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public final fun text (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/ai/prompt/PromptText; + public final fun texts (Ljava/lang/String;)Ljava/util/List; +} + +public abstract interface class com/itsaky/androidide/plugins/ai/prompt/PromptConfigParser { + public abstract fun parse (Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigDocument;)Ljava/lang/Object; +} + +public abstract interface class com/itsaky/androidide/plugins/ai/prompt/PromptConfigProvider { + public abstract fun config (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/itsaky/androidide/plugins/ai/prompt/PromptConfigSource { + public abstract fun read (Ljava/lang/String;)Ljava/lang/String; +} + +public final class com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore : com/itsaky/androidide/plugins/ai/prompt/PromptConfigProvider { + public fun (Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigParser;)V + public final fun clear ()V + public fun config (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun configIfLoaded ()Ljava/lang/Object; + public final fun preload (Lkotlinx/coroutines/CoroutineScope;Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigSource;)Lkotlinx/coroutines/Deferred; +} + +public final class com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine { + public static final field FIRST Ljava/lang/String; + public static final field INSTANCE Lcom/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine; + public static final field LAST Ljava/lang/String; + public final fun render (Lcom/itsaky/androidide/plugins/ai/prompt/PromptText;Ljava/util/Map;)Ljava/lang/String; + public final fun render (Ljava/lang/String;Ljava/util/Map;)Ljava/lang/String; +} + +public final class com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine$RenderException : java/lang/IllegalArgumentException { + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class com/itsaky/androidide/plugins/ai/prompt/PromptText { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/ai/prompt/PromptText; + public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/ai/prompt/PromptText;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/ai/prompt/PromptText; + public fun equals (Ljava/lang/Object;)Z + public final fun getLabel ()Ljava/lang/String; + public final fun getTemplate ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/itsaky/androidide/plugins/ai/ui/ButtonColors { + public fun (IILjava/lang/Integer;Ljava/lang/Integer;)V + public synthetic fun (IILjava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()I + public final fun component3 ()Ljava/lang/Integer; + public final fun component4 ()Ljava/lang/Integer; + public final fun copy (IILjava/lang/Integer;Ljava/lang/Integer;)Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors; + public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors;IILjava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors; + public fun equals (Ljava/lang/Object;)Z + public final fun getContainer ()Ljava/lang/Integer; + public final fun getContent ()I + public final fun getRipple ()I + public final fun getStroke ()Ljava/lang/Integer; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/itsaky/androidide/plugins/ai/ui/FieldColors { + public fun (IIII)V + public final fun component1 ()I + public final fun component2 ()I + public final fun component3 ()I + public final fun component4 ()I + public final fun copy (IIII)Lcom/itsaky/androidide/plugins/ai/ui/FieldColors; + public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/ai/ui/FieldColors;IIIIILjava/lang/Object;)Lcom/itsaky/androidide/plugins/ai/ui/FieldColors; + public fun equals (Ljava/lang/Object;)Z + public final fun getEndIcon ()I + public final fun getError ()I + public final fun getHint ()I + public final fun getStroke ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/itsaky/androidide/plugins/ai/ui/PaneStyle { + public fun (Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors;Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors;Lcom/itsaky/androidide/plugins/ai/ui/FieldColors;IIII)V + public final fun component1 ()Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors; + public final fun component2 ()Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors; + public final fun component3 ()Lcom/itsaky/androidide/plugins/ai/ui/FieldColors; + public final fun component4 ()I + public final fun component5 ()I + public final fun component6 ()I + public final fun component7 ()I + public final fun copy (Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors;Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors;Lcom/itsaky/androidide/plugins/ai/ui/FieldColors;IIII)Lcom/itsaky/androidide/plugins/ai/ui/PaneStyle; + public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/ai/ui/PaneStyle;Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors;Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors;Lcom/itsaky/androidide/plugins/ai/ui/FieldColors;IIIIILjava/lang/Object;)Lcom/itsaky/androidide/plugins/ai/ui/PaneStyle; + public fun equals (Ljava/lang/Object;)Z + public final fun getButtonStrokeWidth ()I + public final fun getCornerRadius ()I + public final fun getDivider ()I + public final fun getDividerThickness ()I + public final fun getField ()Lcom/itsaky/androidide/plugins/ai/ui/FieldColors; + public final fun getFilledButton ()Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors; + public final fun getOutlinedButton ()Lcom/itsaky/androidide/plugins/ai/ui/ButtonColors; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/itsaky/androidide/plugins/ai/ui/PaneStyling { + public static final fun applyPaneStyling (Landroid/view/View;Lcom/itsaky/androidide/plugins/ai/ui/PaneStyle;Ljava/util/Set;)V +} + +public final class com/itsaky/androidide/plugins/ai/ui/RevealToggle { + public fun (II)V + public final fun component1 ()I + public final fun component2 ()I + public final fun copy (II)Lcom/itsaky/androidide/plugins/ai/ui/RevealToggle; + public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/ai/ui/RevealToggle;IIILjava/lang/Object;)Lcom/itsaky/androidide/plugins/ai/ui/RevealToggle; + public fun equals (Ljava/lang/Object;)Z + public final fun getDescription ()I + public final fun getIcon ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/itsaky/androidide/plugins/ai/ui/SecretRevealController { + public fun (Lcom/google/android/material/textfield/TextInputLayout;Landroid/widget/EditText;Lcom/itsaky/androidide/plugins/ai/ui/RevealToggle;Lcom/itsaky/androidide/plugins/ai/ui/RevealToggle;Lkotlin/jvm/functions/Function1;)V + public final fun attach ()V + public final fun isRevealed ()Z + public final fun mask ()V +} + public abstract interface annotation class com/itsaky/androidide/plugins/base/InternalPluginApi : java/lang/annotation/Annotation { } diff --git a/plugin-api/build.gradle.kts b/plugin-api/build.gradle.kts index 4306832d83..a37d2888f6 100644 --- a/plugin-api/build.gradle.kts +++ b/plugin-api/build.gradle.kts @@ -48,6 +48,10 @@ dependencies { // provider via :common -> :logger, so declaring it keeps the compile and runtime views the same. implementation(libs.tooling.slf4j) + // Reads AI plugins' prompt config (ai.prompt.PromptConfigLoader). Host-side for the same reason as + // slf4j: the loader runs in the host's process, so plugins stop bundling a YAML library of their own. + implementation(libs.snakeyaml.engine) + // Test dependencies testImplementation("junit:junit:4.13.2") diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigDocument.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigDocument.kt new file mode 100644 index 0000000000..508055fef0 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigDocument.kt @@ -0,0 +1,93 @@ +package com.itsaky.androidide.plugins.ai.prompt + +import org.snakeyaml.engine.v2.api.Load +import org.snakeyaml.engine.v2.api.LoadSettings +import org.snakeyaml.engine.v2.exceptions.YamlEngineException + +/** + * The prompt config as one mapping, merged from `agent.yml` and the files it includes, remembering + * which file each top-level key came from so an error names the file to fix. + * + * @property values the merged top-level keys, in the order the files were read. + * @property entryFile the file an absent key is reported against. + */ +class PromptConfigDocument private constructor( + val values: Map, + private val origins: Map, + val entryFile: String, +) { + /** + * @param key a top-level key. + * @return the file that defines it, or [entryFile] when none does. + */ + fun fileOf(key: String): String = origins[key] ?: entryFile + + /** + * Reads the top-level mapping with [block], then refuses any top-level key it did not read. + * + * @param block maps the config onto the plugin's own type. + * @return what [block] returned. + */ + fun read(block: PromptConfigObject.() -> T): T = PromptConfigObject(values, "", ::fileOf).read(block) + + companion object { + /** + * Merges files' top-level keys; a key defined in two files throws, since neither may win silently. + * + * @param entryFile the file an absent key is reported against. + * @param files each file's name and its parsed mapping, in read order. + * @return the merged document. + */ + fun merge( + entryFile: String, + files: List>>, + ): PromptConfigDocument { + val values = LinkedHashMap() + val origins = HashMap() + for ((file, map) in files) { + for ((rawKey, value) in map) { + val key = rawKey.toString() + origins.put(key, file)?.let { first -> + throw PromptConfigException("$file: $key is also defined in $first; a key belongs to one file") + } + values[key] = value + } + } + return PromptConfigDocument(values, origins, entryFile) + } + } +} + +/** + * Reads one prompt config file into plain maps and lists, with no class binding, so no reflection. + * Host-side, so plugins do not bundle a YAML library of their own. The library itself drops a + * leading BOM and folds CRLF line ends, which the loader tests pin. + */ +internal object PromptYaml { + /** + * Parses one file; duplicate keys are refused, since an edit to the first would do nothing. + * + * @param text the file's raw text. + * @param fileName the file's name, for error messages. + * @return the file's top-level mapping. + */ + fun read( + text: String, + fileName: String, + ): Map<*, *> { + val document = + try { + Load( + LoadSettings + .builder() + .setLabel(fileName) + // Already the default; stated so a library upgrade cannot flip it unnoticed. + .setAllowDuplicateKeys(false) + .build(), + ).loadFromString(text) + } catch (e: YamlEngineException) { + throw PromptConfigException("$fileName: ${e.message}", e) + } + return document as? Map<*, *> ?: throw PromptConfigException("$fileName: expected a mapping at the top") + } +} diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigException.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigException.kt new file mode 100644 index 0000000000..e701cb861d --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigException.kt @@ -0,0 +1,7 @@ +package com.itsaky.androidide.plugins.ai.prompt + +/** Why a prompt config was refused, naming the file and the key, e.g. `rules.yml: rules[0].items`. */ +class PromptConfigException( + message: String, + cause: Throwable? = null, +) : IllegalArgumentException(message, cause) diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigLoader.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigLoader.kt new file mode 100644 index 0000000000..d229391978 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigLoader.kt @@ -0,0 +1,107 @@ +package com.itsaky.androidide.plugins.ai.prompt + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.FileNotFoundException +import java.io.IOException + +/** + * Maps a merged prompt config onto a plugin's own config type; the only per-plugin part of + * loading. Throw [PromptConfigException] for a missing, mistyped or unknown key. + */ +fun interface PromptConfigParser { + /** + * @param document the merged top-level keys and the file each came from. + * @return the plugin's config. + */ + fun parse(document: PromptConfigDocument): T +} + +/** + * Reads `agent.yml` and the files its `include` lists, off the main thread, and parses them as one. + * Each top-level key lives in exactly one file; which one is up to the files, not this code. + */ +object PromptConfigLoader { + /** The entry file, relative to the source's root. */ + const val FILE = "agent.yml" + + /** The entry file's key listing the other files; read here, never by the parser. */ + private const val INCLUDE = "include" + + /** + * Reads and parses the config; a malformed or missing file throws [PromptConfigException]. + * + * @param source where the config files live. + * @param parser maps the merged document onto the plugin's config type. + * @param dispatcher the dispatcher the blocking reads run on. + * @return the config. + */ + suspend fun load( + source: PromptConfigSource, + parser: PromptConfigParser, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ): T = + withContext(dispatcher) { + val entry = read(source, FILE, "$FILE does not exist") + val files = + buildList { + add(FILE to entry.filterKeys { it != INCLUDE }) + for (file in includes(entry)) { + val included = read(source, file, "$FILE: include names $file, which does not exist") + if (INCLUDE in included) throw PromptConfigException("$file: include is only read in $FILE") + add(file to included) + } + } + parser.parse(PromptConfigDocument.merge(FILE, files)) + } + + /** + * Validates `include`: each entry a `.yml` path under the root, listed once. + * + * @param entry the entry file's mapping. + * @return the files to read, in order; empty when the entry file holds everything itself. + */ + private fun includes(entry: Map<*, *>): List { + if (INCLUDE !in entry) return emptyList() + val list = + entry[INCLUDE] as? List<*> + ?: throw PromptConfigException("$FILE: include expected a list of file names") + val files = list.mapIndexed(::includedFile) + val seen = HashSet() + files.firstOrNull { !seen.add(it) }?.let { throw PromptConfigException("$FILE: include lists $it twice") } + return files + } + + /** One `include` entry, refused unless it names another `.yml` file inside the root. */ + private fun includedFile( + index: Int, + item: Any?, + ): String { + val name = (item as? String)?.trim().orEmpty() + // A backslash and ':' are refused outright: a directory source on a Windows host would read them + // as a separator or a drive, and neither has a use in an asset path. + val escapesRoot = name.startsWith("/") || '\\' in name || ':' in name || ".." in name.split('/') + if (!name.endsWith(".yml") || escapesRoot) { + throw PromptConfigException("$FILE: include[$index] expected a .yml file under prompts/") + } + if (name == FILE) throw PromptConfigException("$FILE: include[$index] names $FILE itself") + return name + } + + private fun read( + source: PromptConfigSource, + file: String, + missing: String, + ): Map<*, *> { + val text = + try { + source.read(file) + } catch (e: FileNotFoundException) { + throw PromptConfigException(missing, e) + } catch (e: IOException) { + throw PromptConfigException("$file: could not be read: ${e.message}", e) + } + return PromptYaml.read(text, file) + } +} diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigObject.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigObject.kt new file mode 100644 index 0000000000..d8f38fd764 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigObject.kt @@ -0,0 +1,121 @@ +package com.itsaky.androidide.plugins.ai.prompt + +/** + * One YAML mapping of a prompt config, read key by key; [read] then refuses any key nothing asked + * for, so a typo fails on activation rather than silently dropping wording. Obtain the root from + * [PromptConfigDocument.read]. Not thread-safe: read one object from one thread. + * + * @param map the parsed mapping. + * @param path where it sits in the config, e.g. `rules[0]`; empty for the root. + * @param fileOf the file a key of this mapping is defined in, for error messages and labels. + */ +class PromptConfigObject internal constructor( + private val map: Map<*, *>, + private val path: String, + private val fileOf: (String) -> String, +) { + private val consumed = HashSet() + + /** Runs [block] against this mapping, then fails on any key it did not read. */ + fun read(block: PromptConfigObject.() -> T): T { + val result = block() + val unknown = map.keys.map { it.toString() }.filterNot { it in consumed } + if (unknown.isNotEmpty()) { + // Reported per file, so the message points at the one file holding the stray key. + val (file, keys) = unknown.groupBy(fileOf).entries.first() + val where = if (path.isEmpty()) "" else "$path: " + val expected = consumed.joinToString() + throw PromptConfigException("$file: ${where}unknown key ${keys.joinToString()}; expected $expected") + } + return result + } + + /** A non-blank string, as config text, exactly as YAML parsed it; a `|` block keeps its final newline, `|-` drops it. */ + fun text(key: String): PromptText = asText(value(key), key, at(key)) + + /** A non-empty list of non-blank strings, as config text. */ + fun texts(key: String): List = nonEmptyList(key).mapIndexed { index, item -> asText(item, key, "${at(key)}[$index]") } + + fun int(key: String): Int = typed(key, "expected a whole number") + + fun obj(key: String): PromptConfigObject = asObject(value(key), key, at(key)) + + fun objects(key: String): List = + nonEmptyList(key).mapIndexed { index, item -> asObject(item, key, "${at(key)}[$index]") } + + /** A non-empty mapping whose keys are data, such as tool names, each value itself a mapping. */ + fun objectEntries(key: String): Map = + nonEmptyEntries(key).mapValues { (name, item) -> asObject(item, key, "${at(key)}.$name") } + + /** A mapping of names to non-blank text, such as argument descriptions; absent means none. */ + fun optionalTextEntries(key: String): Map { + if (key !in map) { + consumed += key + return emptyMap() + } + return nonEmptyEntries(key).mapValues { (name, item) -> asText(item, key, "${at(key)}.$name") } + } + + /** + * @param key the key at fault. + * @param problem what is wrong with it, e.g. `is 2, but this plugin reads 1`. + * @return the exception to throw, naming the file and the key's path. + */ + fun invalid( + key: String, + problem: String, + ) = fail(key, at(key), problem) + + /** Where [key] is declared, as config text labels it, e.g. `tools.yml: tools.heading`. */ + fun labelOf(key: String) = "${fileOf(key)}: ${at(key)}" + + private fun nonEmptyList(key: String): List<*> = typed>(key, "expected a list").ifEmpty { throw invalid(key, "is empty") } + + private fun nonEmptyEntries(key: String): Map = + typed>(key, "expected a mapping") + .ifEmpty { throw invalid(key, "is empty") } + .entries + .associate { (name, item) -> name.toString() to item } + + /** [key]'s value as a [T], or a refusal saying what was [expected]. */ + private inline fun typed( + key: String, + expected: String, + ): T = value(key) as? T ?: throw invalid(key, expected) + + private fun value(key: String): Any? { + consumed += key + if (key !in map) throw invalid(key, "is missing") + return map[key] + } + + /** A nested mapping lives wholly in the file that defines [key]. */ + private fun asObject( + value: Any?, + key: String, + path: String, + ): PromptConfigObject { + val nested = value as? Map<*, *> ?: throw fail(key, path, "expected a mapping") + val file = fileOf(key) + return PromptConfigObject(nested, path) { file } + } + + private fun asText( + value: Any?, + key: String, + path: String, + ): PromptText { + // A number or true/false here is almost always an unquoted value YAML retyped. + val text = value as? String ?: throw fail(key, path, "expected text; quote it") + if (text.isBlank()) throw fail(key, path, "is blank") + return PromptText(text, "${fileOf(key)}: $path") + } + + private fun at(key: String) = if (path.isEmpty()) key else "$path.$key" + + private fun fail( + key: String, + path: String, + problem: String, + ) = PromptConfigException("${fileOf(key)}: $path $problem") +} diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigSource.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigSource.kt new file mode 100644 index 0000000000..c1144206a2 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigSource.kt @@ -0,0 +1,40 @@ +package com.itsaky.androidide.plugins.ai.prompt + +import android.content.res.AssetManager + +/** + * Where prompt config files are read from: the plugin's assets on device, a directory in tests. + * Paths are relative to the source's root; the call blocks, so [PromptConfigLoader] makes it off + * the main thread. + */ +fun interface PromptConfigSource { + /** + * Reads one file. + * + * @param path the file, relative to the root. + * @return the file's text, decoded as UTF-8. + * @throws java.io.FileNotFoundException when there is no such file. + */ + fun read(path: String): String +} + +/** + * Reads config from a directory of the plugin's own assets. + * The [AssetManager] must be the plugin's, not the host's: each plugin sees only its own assets. + * + * @param assets the plugin's asset manager. + * @param root the assets subdirectory holding the config. + */ +class AssetPromptConfigSource + @JvmOverloads + constructor( + private val assets: AssetManager, + private val root: String = DEFAULT_ROOT, + ) : PromptConfigSource { + override fun read(path: String): String = assets.open("$root/$path").bufferedReader(Charsets.UTF_8).use { it.readText() } + + companion object { + /** The assets subdirectory the prompt config ships in. */ + const val DEFAULT_ROOT = "prompts" + } + } diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore.kt new file mode 100644 index 0000000000..ffce65ac0d --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore.kt @@ -0,0 +1,95 @@ +package com.itsaky.androidide.plugins.ai.prompt + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import java.util.concurrent.atomic.AtomicReference + +/** Supplies the loaded prompt config, suspending until it is available. */ +fun interface PromptConfigProvider { + /** + * Returns the config, waiting for an in-flight load if there is one. + * + * @return the loaded config. + */ + suspend fun config(): T +} + +/** + * Caches a plugin's prompt config in memory, loaded once per activation so a turn never reads + * disk. A turn arriving mid-load suspends until it finishes. Thread-safe: the slot is swapped + * atomically, and what it holds is immutable once complete. + * + * A plugin keeps one store for its process, e.g. `val shared = PromptConfigStore(MyParser)`. + * + * @param parser maps the merged config onto the plugin's config type. + */ +class PromptConfigStore( + private val parser: PromptConfigParser, +) : PromptConfigProvider { + private val load = AtomicReference?>(null) + + /** + * Starts loading from [source] unless a load is already cached; a failed load is retried. + * + * @param scope the scope the load runs in; give it a SupervisorJob so a failure stays here. + * @param source where the config file lives. + * @return the load now cached, which callers may observe to log a failure. + */ + fun preload( + scope: CoroutineScope, + source: PromptConfigSource, + ): Deferred { + while (true) { + val current = load.get() + if (current != null && !current.hasFailed()) return current + + val next = + scope.async(start = CoroutineStart.LAZY) { + PromptConfigLoader.load(source, parser) + } + if (load.compareAndSet(current, next)) { + next.start() + return next + } + // Another thread won the swap; drop ours and use theirs. + next.cancel() + } + } + + /** + * Returns the cached config, waiting for an in-flight load; throws when none was started. + * + * @return the loaded config. + */ + override suspend fun config(): T { + val current = checkNotNull(load.get()) { "prompt config was never loaded" } + return current.await() + } + + /** + * Returns the cached config without waiting, for a caller that cannot suspend. + * + * @return the config, or null while a load runs, after one failed, or before any started. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun configIfLoaded(): T? { + val current = load.get() ?: return null + // getCompleted() throws for a load still running or one that failed; neither has a value. + return if (current.isCompleted && !current.hasFailed()) current.getCompleted() else null + } + + /** Drops the cache, cancelling a load still in flight; the next [preload] reads afresh. */ + fun clear() { + load.getAndSet(null)?.cancel() + } + + /** + * Whether this load was cancelled or threw. Checks the completion exception as well as + * [Deferred.isCancelled] rather than relying on the latter alone to report a failure. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun Deferred<*>.hasFailed(): Boolean = isCancelled || (isCompleted && getCompletionExceptionOrNull() != null) +} diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine.kt new file mode 100644 index 0000000000..feb6d312a1 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine.kt @@ -0,0 +1,240 @@ +package com.itsaky.androidide.plugins.ai.prompt + +/** + * Renders a prompt template: `{{NAME}}` values, `{{#NAME}}...{{/NAME}}` and `{{^NAME}}...{{/NAME}}` + * sections; a name is a letter then letters, digits, `_` or `.`, in any case, e.g. `{{FILE_NAME}}`, + * `{{fileName}}` or `{{item.name}}`, looked up as the whole key. Strict, so a typo throws; a String value is never rescanned, a [PromptText] always is. + * Stateless and free of reflection, so it is safe to call from any thread. + */ +object PromptTemplateEngine { + /** Set in each list item's scope: whether it is the list's first item. */ + const val FIRST = "FIRST" + + /** Set in each list item's scope: whether it is the list's last item. */ + const val LAST = "LAST" + + private val TAG = Regex("""\{\{(?:([#^/])([a-zA-Z][a-zA-Z0-9_.]*)|([a-zA-Z][a-zA-Z0-9_.]*))\}\}""") + + /** Deep enough for any sane nesting of [PromptText]; stops one that names itself. */ + private const val MAX_DEPTH = 8 + + /** What may share a line with a standalone section tag. */ + private const val INDENT = " \t" + + /** + * Renders [template] against [values]. + * + * @param template the template text. + * @param values each name's value: a String, a [PromptText], a Boolean, null, or a list of maps. + * @return the rendered text. + */ + fun render( + template: String, + values: Map, + ): String = buildString { render(parse(template), listOf(values), 0, this) } + + /** + * Renders config text against [values]; a failure names [PromptText.label]. + * + * @param text the config text. + * @param values each name's value, as for the other overload. + * @return the rendered text. + */ + fun render( + text: PromptText, + values: Map, + ): String = buildString { renderText(text, listOf(values), 0, this) } + + /** A render failure already located in one [PromptText], so enclosing ones leave it as is. */ + class RenderException( + message: String, + cause: Throwable? = null, + ) : IllegalArgumentException(message, cause) + + private sealed interface Node { + data class Text( + val text: String, + ) : Node + + data class Value( + val name: String, + ) : Node + + data class Section( + val name: String, + val inverted: Boolean, + val children: List, + ) : Node + } + + private fun renderText( + text: PromptText, + scopes: List>, + depth: Int, + out: StringBuilder, + ) { + try { + require(depth < MAX_DEPTH) { "nests too deep" } + render(parse(text.template), scopes, depth + 1, out) + } catch (e: IllegalArgumentException) { + throw e as? RenderException ?: RenderException("${text.label}: ${e.message}", e) + } + } + + private fun render( + nodes: List, + scopes: List>, + depth: Int, + out: StringBuilder, + ) { + for (node in nodes) { + when (node) { + is Node.Text -> { + out.append(node.text) + } + + is Node.Value -> { + when (val value = lookup(node.name, scopes)) { + is String -> out.append(value) + is PromptText -> renderText(value, scopes, depth, out) + null -> throw IllegalArgumentException("no value for {{${node.name}}}") + else -> throw IllegalArgumentException("{{${node.name}}} is not text") + } + } + + is Node.Section -> { + section(node, scopes, depth, out) + } + } + } + } + + private fun section( + node: Node.Section, + scopes: List>, + depth: Int, + out: StringBuilder, + ) { + val value = lookup(node.name, scopes) + when { + node.inverted -> { + if (!isTruthy(value)) render(node.children, scopes, depth, out) + } + + value is List<*> -> { + value.forEachIndexed { index, item -> + @Suppress("UNCHECKED_CAST") + val scope = + requireNotNull(item as? Map) { + "{{#${node.name}}} items must be maps" + } + val position = mapOf(FIRST to (index == 0), LAST to (index == value.lastIndex)) + render(node.children, scopes + position + scope, depth, out) + } + } + + isTruthy(value) -> { + render(node.children, scopes, depth, out) + } + } + } + + /** False for null, `false`, empty text and an empty list; true for anything else. */ + private fun isTruthy(value: Any?): Boolean = + when (value) { + null, false -> false + is String -> value.isNotEmpty() + is PromptText -> value.template.isNotEmpty() + is List<*> -> value.isNotEmpty() + else -> true + } + + /** Finds [name] in the innermost scope that has it; a name no scope has is a typo. */ + private fun lookup( + name: String, + scopes: List>, + ): Any? { + val scope = requireNotNull(scopes.lastOrNull { name in it }) { "unknown name {{$name}}" } + return scope[name] + } + + /** + * Parses [template] into nodes, removing the lines that hold only a section tag. + * + * @param template the template text. + * @return the top-level nodes. + */ + private fun parse(template: String): List { + val root = mutableListOf() + // Each open section: its opening tag and the nodes collected for it so far. + val open = ArrayDeque>>() + + fun current() = open.lastOrNull()?.second ?: root + + var position = 0 + for (match in TAG.findAll(template)) { + if (match.range.first < position) continue + val (marker, sectionName, valueName) = match.destructured + var textEnd = match.range.first + var next = match.range.last + 1 + if (marker.isNotEmpty()) { + standaloneLine(template, match.range, position)?.let { (start, end) -> + textEnd = start + next = end + } + } + if (textEnd > position) current().add(Node.Text(template.substring(position, textEnd))) + position = next + + when (marker) { + "#", "^" -> { + open.addLast(Node.Section(sectionName, marker == "^", emptyList()) to mutableListOf()) + } + + "/" -> { + val (tag, children) = + requireNotNull(open.removeLastOrNull()) { + "{{/$sectionName}} closes no section" + } + require(tag.name == sectionName) { "{{/$sectionName}} closes {{#${tag.name}}}" } + current().add(tag.copy(children = children)) + } + + else -> { + current().add(Node.Value(valueName)) + } + } + } + require(open.isEmpty()) { "{{#${open.last().first.name}}} is never closed" } + if (position < template.length) root.add(Node.Text(template.substring(position))) + return root + } + + /** + * Finds the span to remove when a tag is alone on its line: the line's indent up to its newline. + * + * @param template the template text. + * @param tag where the tag is. + * @param notBefore where the text not yet consumed starts. + * @return the span's start and end, or null when the line holds anything besides the tag. + */ + private fun standaloneLine( + template: String, + tag: IntRange, + notBefore: Int, + ): Pair? { + val lineStart = maxOf(template.lastIndexOf('\n', tag.first - 1) + 1, notBefore) + if (!template.onlyHas(INDENT, lineStart, tag.first)) return null + if (lineStart == notBefore && lineStart > 0 && template[lineStart - 1] != '\n') return null + val newline = template.indexOf('\n', tag.last + 1) + val lineEnd = if (newline < 0) template.length else newline + if (!template.onlyHas("$INDENT\r", tag.last + 1, lineEnd)) return null + return lineStart to if (newline < 0) lineEnd else newline + 1 + } + + private fun String.onlyHas( + chars: String, + start: Int, + end: Int, + ): Boolean = (start until end).all { this[it] in chars } +} diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptText.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptText.kt new file mode 100644 index 0000000000..a6b93b45ff --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptText.kt @@ -0,0 +1,13 @@ +package com.itsaky.androidide.plugins.ai.prompt + +/** + * Prompt wording from config: itself a template, so [PromptTemplateEngine] renders it where it is + * placed, with the values in scope there. A plain String value is inserted verbatim instead. + * + * @property template the text, with its `{{NAME}}` tags. + * @property label where the text came from, e.g. `rules.yml: rules[0].items[1]`, for error messages. + */ +data class PromptText( + val template: String, + val label: String, +) diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/PaneStyling.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/PaneStyling.kt new file mode 100644 index 0000000000..485168364f --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/PaneStyling.kt @@ -0,0 +1,129 @@ +@file:JvmName("PaneStyling") + +package com.itsaky.androidide.plugins.ai.ui + +import android.content.res.ColorStateList +import android.graphics.Color +import android.view.View +import android.view.ViewGroup +import androidx.annotation.ColorRes +import androidx.annotation.DimenRes +import androidx.core.content.ContextCompat +import com.google.android.material.button.MaterialButton +import com.google.android.material.divider.MaterialDivider +import com.google.android.material.textfield.TextInputLayout + +/** + * One button emphasis, from the plugin's own colour resources. + * + * @property content the label and icon + * @property ripple the ripple + * @property container the fill; null leaves the button transparent + * @property stroke the border; null draws none + */ +data class ButtonColors( + @ColorRes val content: Int, + @ColorRes val ripple: Int, + @ColorRes val container: Int? = null, + @ColorRes val stroke: Int? = null, +) + +/** + * An outlined-box text field, from the plugin's own colour resources. + * + * @property stroke the outline + * @property error the outline while the field shows an error + * @property hint the hint + * @property endIcon the end icon's tint + */ +data class FieldColors( + @ColorRes val stroke: Int, + @ColorRes val error: Int, + @ColorRes val hint: Int, + @ColorRes val endIcon: Int, +) + +/** + * The plugin's own resources a settings pane is dressed in. They resolve against each view's + * context, which carries the plugin's resources, not the host's. + * + * @property filledButton a section's primary action + * @property outlinedButton every secondary action + * @property field every text field + * @property divider a divider's hairline colour + * @property buttonStrokeWidth a bordered button's border width + * @property cornerRadius the corner radius of buttons and text fields + * @property dividerThickness a divider's thickness + */ +data class PaneStyle( + val filledButton: ButtonColors, + val outlinedButton: ButtonColors, + val field: FieldColors, + @ColorRes val divider: Int, + @DimenRes val buttonStrokeWidth: Int, + @DimenRes val cornerRadius: Int, + @DimenRes val dividerThickness: Int, +) + +/** + * Gives every Material button, text field and divider under [this] its Material 3 colours, outline + * and ripple in code. The styles' `app:` items are dropped inside the host, so XML alone leaves + * these controls on the host theme's values. + * + * @param style the plugin's resources to apply. + * @param outlinedButtonIds the buttons that are secondary actions; every other button is filled, + * so a section has one filled action and the rest outlined. + * + * A styled control's own children are left alone: they are the control's internals, not the pane's. + */ +fun View.applyPaneStyling( + style: PaneStyle, + outlinedButtonIds: Set, +) { + when (this) { + is MaterialButton -> applyColors(if (id in outlinedButtonIds) style.outlinedButton else style.filledButton, style) + is TextInputLayout -> applyColors(style.field, style) + is MaterialDivider -> applyHairline(style) + is ViewGroup -> for (i in 0 until childCount) getChildAt(i).applyPaneStyling(style, outlinedButtonIds) + } +} + +/** Container, label, icon, border and ripple, each with its disabled state. */ +private fun MaterialButton.applyColors( + colors: ButtonColors, + style: PaneStyle, +) { + val content = colorList(colors.content) + backgroundTintList = colors.container?.let { colorList(it) } ?: ColorStateList.valueOf(Color.TRANSPARENT) + setTextColor(content) + iconTint = content + rippleColor = colorList(colors.ripple) + colors.stroke?.let { strokeColor = colorList(it) } + strokeWidth = if (colors.stroke == null) 0 else resources.getDimensionPixelSize(style.buttonStrokeWidth) + cornerRadius = resources.getDimensionPixelSize(style.cornerRadius) +} + +/** Outline, corners, hint and end icon of an outlined-box field. */ +private fun TextInputLayout.applyColors( + colors: FieldColors, + style: PaneStyle, +) { + setBoxStrokeColorStateList(colorList(colors.stroke)) + setBoxStrokeErrorColor(colorList(colors.error)) + val radius = resources.getDimension(style.cornerRadius) + setBoxCornerRadii(radius, radius, radius, radius) + val hint = colorList(colors.hint) + defaultHintTextColor = hint + hintTextColor = hint + setEndIconTintList(colorList(colors.endIcon)) +} + +private fun MaterialDivider.applyHairline(style: PaneStyle) { + setDividerColorResource(style.divider) + setDividerThicknessResource(style.dividerThickness) +} + +/** Resolved against this view's context, which carries the plugin's resources. */ +private fun View.colorList( + @ColorRes id: Int, +): ColorStateList = requireNotNull(ContextCompat.getColorStateList(context, id)) diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/SecretRevealController.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/SecretRevealController.kt new file mode 100644 index 0000000000..b4a8780745 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/SecretRevealController.kt @@ -0,0 +1,114 @@ +package com.itsaky.androidide.plugins.ai.ui + +import android.text.method.HideReturnsTransformationMethod +import android.text.method.PasswordTransformationMethod +import android.widget.EditText +import androidx.annotation.DrawableRes +import androidx.annotation.MainThread +import androidx.annotation.StringRes +import com.google.android.material.textfield.TextInputLayout + +/** + * What the reveal control shows in one state: the plugin's own icon and content description. + * + * @property icon the end icon's drawable + * @property description the end icon's content description, which is what tells TalkBack the state + */ +data class RevealToggle( + @DrawableRes val icon: Int, + @StringRes val description: Int, +) + +/** + * The reveal control for a plugin's masked credential input. + * + * The control is the field's own [TextInputLayout] end icon rather than a loose `ImageButton`, + * which is what gives it a real touch target wherever the field is shown. Toggle behaviour, masking + * and accessibility are decided here once, so the AI plugins' credential fields cannot drift apart + * (ADFA-5491). + * + * The toggles' resources resolve against [box]'s context, which carries the plugin's resources, + * not the host's. + * + * @param box the field's own layout, whose end icon becomes the control + * @param input the masked field + * @param reveal what the control shows while the secret is masked + * @param hide what the control shows while the secret stands in clear text + * @param onLegibleChanged called with true when the secret comes to stand in clear text and false once + * it is masked again, so the caller can flag its window secure - which window that is depends on + * the screen, not on this control. Not called by [attach], which leaves the secret masked. + */ +@MainThread +class SecretRevealController( + private val box: TextInputLayout, + private val input: EditText, + private val reveal: RevealToggle, + private val hide: RevealToggle, + private val onLegibleChanged: (legible: Boolean) -> Unit, +) { + /** Whether the secret currently stands in clear text. */ + var isRevealed: Boolean = false + private set(value) { + field = value + render() + report() + } + + /** + * Take over [box]'s end icon and mask the input. + * + * The drawable is set here rather than in the layout because an end icon declared as + * `app:endIconDrawable` draws blank inside the host. + */ + fun attach() { + box.endIconMode = TextInputLayout.END_ICON_CUSTOM + // Not announced as a toggle: with END_ICON_CUSTOM nothing ever moves the icon's checked + // state, so TalkBack would read "not checked" over a legible secret. The content + // description carries the state instead. + box.isEndIconCheckable = false + box.setEndIconOnClickListener { isRevealed = !isRevealed } + render() + } + + /** + * Re-mask the secret and report it illegible. + * + * Call it when the screen leaves the foreground and when a new secret is loaded, so neither a + * screenshot nor the recents thumbnail can catch a revealed credential. + */ + fun mask() { + if (isRevealed) isRevealed = false + } + + /** Dress the field and its icon for [isRevealed]. */ + private fun render() { + val revealed = isRevealed + // Swapping the transformation drops the cursor to the start; put back wherever the user had it. + val start = input.selectionStart + val end = input.selectionEnd + input.transformationMethod = + if (revealed) HideReturnsTransformationMethod.getInstance() else PasswordTransformationMethod.getInstance() + if (start >= 0 && end >= 0) input.setSelection(start, end) + val toggle = if (revealed) hide else reveal + box.setEndIconDrawable(toggle.icon) + box.setEndIconContentDescription(toggle.description) + } + + /** Tell the caller what is now legible; masking only invalidates, so false waits for the redraw. */ + private fun report() { + if (isRevealed) onLegibleChanged(true) else afterNextDraw { if (!isRevealed) onLegibleChanged(false) } + } + + /** + * Run [action] once the next frame has been drawn, or right away if the input is already gone: + * an animation callback runs before that frame's traversal, so a message posted from it lands + * after the field has been redrawn. + */ + private fun afterNextDraw(action: () -> Unit) { + if (input.isAttachedToWindow) { + input.postOnAnimation { input.post(action) } + } else { + action() + } + } +} diff --git a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigFixture.kt b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigFixture.kt new file mode 100644 index 0000000000..9500aa6da6 --- /dev/null +++ b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigFixture.kt @@ -0,0 +1,123 @@ +package com.itsaky.androidide.plugins.ai.prompt + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import java.io.FileNotFoundException + +/** A small plugin's config type, standing in for each AI plugin's own. */ +data class FixtureConfig( + val identity: PromptText, + val rules: List, + val systemPrompt: PromptText, + val tools: Map, +) { + data class Rule( + val heading: PromptText, + val items: List, + ) + + data class Tool( + val description: PromptText, + val arguments: Map, + ) +} + +/** A config shaped like the AI plugins' `assets/prompts/`, held in memory so a test can rewrite any file. */ +object PromptConfigFixture { + const val SCHEMA_VERSION = 1 + + val parser = + PromptConfigParser { document -> + document.read { + val version = int("schema_version") + if (version != SCHEMA_VERSION) { + throw invalid("schema_version", "is $version, but this plugin reads $SCHEMA_VERSION") + } + FixtureConfig( + identity = text("identity"), + rules = objects("rules").map { it.read { FixtureConfig.Rule(text("heading"), texts("items")) } }, + systemPrompt = obj("layout").read { text("system_prompt") }, + tools = + objectEntries("tools").mapValues { (_, tool) -> + tool.read { FixtureConfig.Tool(text("description"), optionalTextEntries("arguments")) } + }, + ) + } + } + + val files: Map = + mapOf( + "agent.yml" to + """ + |schema_version: 1 + |identity: > + | You help with any question, + | not only Android questions. + |include: + | - rules.yml + | - layout.yml + | + """.trimMargin(), + "rules.yml" to + """ + |rules: + | - heading: IMPORTANT + | items: + | - Reply briefly. + | - Call {{TOOL}} to read a file. + | - heading: STYLE + | items: + | - Use plain words. + | + """.trimMargin(), + "layout.yml" to + """ + |layout: + | system_prompt: | + | {{IDENTITY}} + | {{RULE}} + |tools: + | read_file: + | description: Reads a file. + | arguments: + | path: The file to read. + | list_files: + | description: Lists a directory. + | + """.trimMargin(), + ) + + /** + * Loads config from in-memory files; a path not in [files] does not exist. + * + * @param files each file's path and text. + * @return the loaded config. + */ + fun load(files: Map = this.files): FixtureConfig = runBlocking { PromptConfigLoader.load(sourceOf(files), parser) } + + /** Loads the fixture with [file] rewritten by [edit]. */ + fun loadWith( + file: String, + edit: (String) -> String, + ): FixtureConfig = load(files + (file to edit(files.getValue(file)))) + + fun sourceOf(files: Map) = PromptConfigSource { path -> files[path] ?: throw FileNotFoundException(path) } +} + +/** Asserts [block] refuses the config, returning the refusal. */ +fun refused(block: () -> Unit): PromptConfigException = assertThrows(PromptConfigException::class.java) { block() } + +/** Asserts [block] refuses the config with exactly [message]. */ +fun assertRefused( + message: String, + block: () -> Unit, +) = assertEquals(message, refused(block).message) + +/** A source over the fixture whose every read blocks until [gate] completes. */ +fun gatedSource(gate: CompletableDeferred) = + PromptConfigSource { path -> + runBlocking { gate.await() } + PromptConfigFixture.files.getValue(path) + } diff --git a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigLoaderTest.kt b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigLoaderTest.kt new file mode 100644 index 0000000000..fae91b0714 --- /dev/null +++ b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigLoaderTest.kt @@ -0,0 +1,166 @@ +package com.itsaky.androidide.plugins.ai.prompt + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigFixture.files +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigFixture.load +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigFixture.loadWith +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigFixture.parser +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * Unit tests for [PromptConfigLoader] and [PromptConfigDocument]: `agent.yml` and its `include` + * list become one config, and a mistake in any file is refused naming that file. + */ +class PromptConfigLoaderTest { + @Test + fun givenAnEntryFileWithIncludes_whenLoading_thenItAndEveryIncludeAreReadInOrder() { + val paths = mutableListOf() + val source = + PromptConfigSource { path -> + paths += path + files.getValue(path) + } + + runBlocking { PromptConfigLoader.load(source, parser) } + + assertEquals(listOf("agent.yml", "rules.yml", "layout.yml"), paths) + } + + @Test + fun givenNoInclude_whenLoading_thenOneFileMayHoldTheWholeConfig() { + val single = + files.getValue("agent.yml").substringBefore("include:") + + (files - "agent.yml").values.joinToString("\n") + + val config = load(mapOf("agent.yml" to single)) + + assertEquals("agent.yml: rules[0].items[0]", config.rules[0].items[0].label) + } + + @Test + fun givenAKeyInTwoFiles_whenLoading_thenBothFilesAreNamed() { + // Neither copy may win silently: an edit to the losing one would change nothing. + assertRefused("layout.yml: identity is also defined in agent.yml; a key belongs to one file") { + loadWith("layout.yml") { "$it\nidentity: Twice.\n" } + } + } + + @Test + fun givenAMissingInclude_whenLoading_thenTheMissingFileIsNamed() { + assertRefused("agent.yml: include names rules.yml, which does not exist") { load(files - "rules.yml") } + } + + @Test + fun givenNoEntryFile_whenLoading_thenItIsNamed() { + assertRefused("agent.yml does not exist") { load(files - "agent.yml") } + } + + @Test + fun givenAnIncludeListedTwice_whenLoading_thenItIsRefused() { + assertRefused("agent.yml: include lists rules.yml twice") { + loadWith("agent.yml") { it.replace(" - rules.yml\n", " - rules.yml\n - rules.yml\n") } + } + } + + @Test + fun givenAnIncludeOutsideTheRoot_whenLoading_thenItIsRefused() { + val escapes = listOf("../rules.yml", "/rules.yml", "sub/../../rules.yml", "..\\rules.yml", "C:\\rules.yml", "file:rules.yml") + for (escape in escapes) { + assertRefused("agent.yml: include[0] expected a .yml file under prompts/") { + loadWith("agent.yml") { it.replace(" - rules.yml\n", " - $escape\n") } + } + } + } + + @Test + fun givenAFileThatCannotBeRead_whenLoading_thenTheRefusalNamesIt() { + val source = + PromptConfigSource { path -> + if (path == "rules.yml") throw IOException("Permission denied") + files.getValue(path) + } + + assertRefused("rules.yml: could not be read: Permission denied") { + runBlocking { PromptConfigLoader.load(source, parser) } + } + } + + @Test + fun givenAnIncludeThatIsNotYaml_whenLoading_thenItIsRefused() { + assertRefused("agent.yml: include[0] expected a .yml file under prompts/") { + loadWith("agent.yml") { it.replace(" - rules.yml\n", " - rules.txt\n") } + } + } + + @Test + fun givenAnIncludeNamingTheEntryFile_whenLoading_thenItIsRefused() { + assertRefused("agent.yml: include[0] names agent.yml itself") { + loadWith("agent.yml") { it.replace(" - rules.yml\n", " - agent.yml\n") } + } + } + + @Test + fun givenAnIncludeThatIsNotAList_whenLoading_thenItIsRefused() { + assertRefused("agent.yml: include expected a list of file names") { + loadWith("agent.yml") { it.replace(Regex("(?s)include:.*"), "include: rules.yml\n") } + } + } + + @Test + fun givenAnIncludedFileWithItsOwnInclude_whenLoading_thenItIsRefused() { + // One level only, so the whole prompt is always listed in agent.yml. + assertRefused("rules.yml: include is only read in agent.yml") { + loadWith("rules.yml") { "$it\ninclude:\n - more.yml\n" } + } + } + + @Test + fun givenAnEmptyIncludedFile_whenLoading_thenItIsRefused() { + assertRefused("layout.yml: expected a mapping at the top") { loadWith("layout.yml") { "# nothing yet\n" } } + } + + @Test + fun givenEditorArtifacts_whenLoading_thenABomAndCrlfLineEndsAreAccepted() { + val config = loadWith("layout.yml") { "\uFEFF" + it.replace("\n", "\r\n") } + + assertFalse(config.systemPrompt.template.contains('\r')) + assertEquals("layout.yml: layout.system_prompt", config.systemPrompt.label) + } + + @Test + fun givenABomOnTheEntryFile_whenLoading_thenItsFirstKeyIsStillRead() { + // A BOM glued to `schema_version` would make the parser report that key missing. + val config = loadWith("agent.yml") { "\uFEFF$it" } + + assertEquals("agent.yml: identity", config.identity.label) + } + + @Test + fun givenBrokenYaml_whenLoading_thenTheFileNameAndPositionAreReported() { + val error = refused { loadWith("rules.yml") { "rules: [unclosed" } } + + assertTrue(error.message!!.startsWith("rules.yml: ")) + assertTrue(error.message!!.contains("line")) + } + + @Test + fun givenADuplicateKeyInOneFile_whenLoading_thenItIsRefused() { + // YAML would otherwise keep the second silently, and an edit to the first would do nothing. + val error = refused { loadWith("agent.yml") { "$it\nidentity: again\n" } } + + assertTrue(error.message!!.startsWith("agent.yml: ")) + } + + @Test + fun givenTheParser_whenLoading_thenItReceivesTheMergedDocumentWithoutInclude() { + val document = runBlocking { PromptConfigLoader.load(PromptConfigFixture.sourceOf(files), { it }) } + + assertEquals(listOf("schema_version", "identity", "rules", "layout", "tools"), document.values.keys.toList()) + assertEquals("rules.yml", document.fileOf("rules")) + assertEquals("agent.yml", document.fileOf("absent")) + } +} diff --git a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigObjectTest.kt b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigObjectTest.kt new file mode 100644 index 0000000000..f8f7b1cc3c --- /dev/null +++ b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigObjectTest.kt @@ -0,0 +1,164 @@ +package com.itsaky.androidide.plugins.ai.prompt + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigFixture.load +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigFixture.loadWith +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [PromptConfigObject], what a plugin's parser reads its config through: a mistake + * in a prompt file is refused naming that file and the key, rather than reaching the model as a + * prompt with a hole in it. + */ +class PromptConfigObjectTest { + private val config = load() + + @Test + fun givenTheFiles_whenParsing_thenEveryTextIsLabelledWithItsOwnFileAndPath() { + assertEquals("agent.yml: identity", config.identity.label) + assertEquals("rules.yml: rules[0].items[1]", config.rules[0].items[1].label) + assertEquals("layout.yml: layout.system_prompt", config.systemPrompt.label) + val argument = + config.tools + .getValue("read_file") + .arguments + .getValue("path") + assertEquals("layout.yml: tools.read_file.arguments.path", argument.label) + } + + @Test + fun givenABlockScalar_whenParsing_thenItsTrailingNewlineIsKept() { + // Whitespace can matter in a code block, so the text reaches the template as YAML parsed it. + assertEquals("{{IDENTITY}}\n{{RULE}}\n", config.systemPrompt.template) + } + + @Test + fun givenAFoldedScalar_whenParsing_thenItsLinesAreJoinedIntoOneSentence() { + // Source line wraps must not reach the model as newlines mid-sentence. + assertEquals("You help with any question, not only Android questions.\n", config.identity.template) + } + + @Test + fun givenAnAbsentOptionalMapping_whenParsing_thenItIsEmpty() { + assertEquals(emptyMap(), config.tools.getValue("list_files").arguments) + } + + @Test + fun givenAMissingNestedKey_whenParsing_thenItIsNamedWithItsFileAndPath() { + assertRefused("rules.yml: rules[0].heading is missing") { + loadWith("rules.yml") { it.replace(" - heading: IMPORTANT\n items:", " - items:") } + } + } + + @Test + fun givenAMissingTopLevelKey_whenParsing_thenItIsReportedAgainstTheEntryFile() { + // No file holds it, so the entry file, which decides what is read, is the one to fix. + assertRefused("agent.yml: rules is missing") { loadWith("rules.yml") { "other: x\n" } } + } + + @Test + fun givenAMisspelledKey_whenParsing_thenTheRealKeyIsReportedMissing() { + val error = + refused { + loadWith("rules.yml") { it.replace(" items:\n - Reply", " itmes:\n - Reply") } + } + + assertEquals("rules.yml: rules[0].items is missing", error.message) + } + + @Test + fun givenAnExtraNestedKey_whenParsing_thenItIsRefusedAsUnknown() { + assertRefused("layout.yml: layout: unknown key tone; expected system_prompt") { + loadWith("layout.yml") { it.replace("layout:\n", "layout:\n tone: friendly\n") } + } + } + + @Test + fun givenAnExtraTopLevelKey_whenParsing_thenTheFileHoldingItIsNamed() { + val error = refused { loadWith("layout.yml") { "$it\ntone: friendly\n" } } + + assertTrue(error.message!!, error.message!!.startsWith("layout.yml: unknown key tone; expected ")) + } + + @Test + fun givenAnUnquotedNumber_whenParsing_thenItIsRefusedAsNotText() { + assertRefused("rules.yml: rules[1].heading expected text; quote it") { + loadWith("rules.yml") { it.replace("heading: STYLE", "heading: 42") } + } + } + + @Test + fun givenABlankText_whenParsing_thenItIsRefused() { + assertRefused("rules.yml: rules[1].items[0] is blank") { + loadWith("rules.yml") { it.replace("- Use plain words.", "- \" \"") } + } + } + + @Test + fun givenAnEmptyList_whenParsing_thenItIsRefused() { + assertRefused("rules.yml: rules[1].items is empty") { + loadWith("rules.yml") { it.replace(Regex("(?s)(- heading: STYLE\n items:).*"), "$1 []\n") } + } + } + + @Test + fun givenAListItemThatIsNotAMapping_whenParsing_thenItIsRefused() { + assertRefused("rules.yml: rules[0] expected a mapping") { + loadWith("rules.yml") { "rules:\n - just text\n" } + } + } + + @Test + fun givenAnEmptyEntryMapping_whenParsing_thenItIsRefused() { + assertRefused("layout.yml: tools is empty") { + loadWith("layout.yml") { it.replace(Regex("(?s)tools:.*"), "tools: {}\n") } + } + } + + @Test + fun givenAWholeNumberAsText_whenParsingAnInt_thenItIsRefused() { + assertRefused("agent.yml: schema_version expected a whole number") { + loadWith("agent.yml") { it.replace("schema_version: 1", "schema_version: \"1\"") } + } + } + + @Test + fun givenAParserRefusal_whenParsing_thenItNamesTheFileAndKey() { + assertRefused("agent.yml: schema_version is 2, but this plugin reads 1") { + loadWith("agent.yml") { it.replace("schema_version: 1", "schema_version: 2") } + } + } + + @Test + fun givenAKey_whenLabelled_thenItNamesTheFileDefiningIt() { + val files = listOf("agent.yml" to mapOf("a" to "x"), "tools.yml" to mapOf("heading" to "H")) + val document = PromptConfigDocument.merge("agent.yml", files) + + val label = + document.read { + labelOf("heading").also { + text("heading") + text("a") + } + } + + assertEquals("tools.yml: heading", label) + } + + @Test + fun givenLoadedText_whenRendered_thenTheEngineExpandsIt() { + val values = + mapOf( + "IDENTITY" to config.identity, + "RULE" to config.rules[0].items[1], + "TOOL" to "read_file", + ) + + val out = PromptTemplateEngine.render(config.systemPrompt, values) + + // Each block keeps its final newline, so the identity's own newline and the layout's both land. + val expected = "You help with any question, not only Android questions.\n\nCall read_file to read a file.\n" + assertEquals(expected, out) + } +} diff --git a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStoreTest.kt b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStoreTest.kt new file mode 100644 index 0000000000..fd9175262e --- /dev/null +++ b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStoreTest.kt @@ -0,0 +1,139 @@ +package com.itsaky.androidide.plugins.ai.prompt + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigFixture.files +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigFixture.parser +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +/** Unit tests for [PromptConfigStore], the in-memory cache every chat turn reads from. */ +class PromptConfigStoreTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val store = PromptConfigStore(parser) + + @After + fun tearDown() { + scope.cancel() + } + + /** A source over the fixture that counts loads (reads of the entry file) and can throw. */ + private class CountingSource( + private val fail: Boolean = false, + ) : PromptConfigSource { + val scans = AtomicInteger() + + override fun read(path: String): String { + if (path == PromptConfigLoader.FILE) scans.incrementAndGet() + if (fail) error("assets missing") + return files.getValue(path) + } + } + + @Test + fun givenNoPreload_whenReading_thenItFailsRatherThanWaitingForever() { + assertThrows(IllegalStateException::class.java) { runBlocking { store.config() } } + } + + @Test + fun givenAPreload_whenManyTurnsReadConcurrently_thenTheSourceIsReadOnce() { + val source = CountingSource() + store.preload(scope, source) + + val results = + runBlocking { + (1..50).map { async(Dispatchers.Default) { store.config() } }.awaitAll() + } + + assertEquals(1, source.scans.get()) + results.forEach { assertSame(results.first(), it) } + } + + @Test + fun givenConcurrentPreloads_whenReading_thenOnlyOneLoadRuns() { + val source = CountingSource() + + runBlocking { + (1..20).map { async(Dispatchers.Default) { store.preload(scope, source) } }.awaitAll() + store.config() + } + + assertEquals(1, source.scans.get()) + } + + @Test + fun givenAFailedLoad_whenPreloadingAgain_thenTheLoadIsRetried() { + store.preload(scope, CountingSource(fail = true)).settle() + val retry = CountingSource() + + val config = + runBlocking { + store.preload(scope, retry) + store.config() + } + + assertEquals("agent.yml: identity", config.identity.label) + assertEquals(1, retry.scans.get()) + } + + @Test + fun givenAClearedStore_whenPreloadingAgain_thenTheSourceIsReadAfresh() { + val source = CountingSource() + runBlocking { store.preload(scope, source).await() } + + store.clear() + runBlocking { store.preload(scope, source).await() } + + assertEquals(2, source.scans.get()) + } + + @Test + fun givenATurnBeforeTheLoadFinishes_whenReading_thenItWaitsForTheLoad() { + val gate = CompletableDeferred() + store.preload(scope, gatedSource(gate)) + + val config = + runBlocking { + val turn = async(Dispatchers.Default) { store.config() } + gate.complete(Unit) + turn.await() + } + + assertEquals("agent.yml: identity", config.identity.label) + } + + @Test + fun givenACompletedLoad_whenReadingWithoutWaiting_thenTheConfigIsReturned() { + val loaded = runBlocking { store.preload(scope, CountingSource()).await() } + + assertSame(loaded, store.configIfLoaded()) + } + + @Test + fun givenNoCompletedLoad_whenReadingWithoutWaiting_thenNothingIsReturned() { + assertNull(store.configIfLoaded()) + + val gate = CompletableDeferred() + store.preload(scope, gatedSource(gate)) + assertNull(store.configIfLoaded()) + gate.complete(Unit) + store.clear() + + store.preload(scope, CountingSource(fail = true)).settle() + assertNull(store.configIfLoaded()) + } + + private fun Deferred<*>.settle() = runBlocking { runCatching { await() } } +} diff --git a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngineTest.kt b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngineTest.kt new file mode 100644 index 0000000000..7246db8fc5 --- /dev/null +++ b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngineTest.kt @@ -0,0 +1,229 @@ +package com.itsaky.androidide.plugins.ai.prompt + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** Unit tests for [PromptTemplateEngine], which renders every AI plugin's prompt layouts in one pass. */ +class PromptTemplateEngineTest { + @Test + fun givenPlaceholdersWithValues_whenRendering_thenEachIsReplaced() { + val out = PromptTemplateEngine.render("{{A}} and {{B}}, {{A}}", mapOf("A" to "x", "B" to "y")) + + assertEquals("x and y, x", out) + } + + @Test + fun givenAStringThatLooksLikeATag_whenRendering_thenItIsNotExpandedAgain() { + // A contributed tool's description is third-party text and must reach the model as written. + val out = PromptTemplateEngine.render("{{A}}", mapOf("A" to "{{B}} {{#B}}", "B" to "no")) + + assertEquals("{{B}} {{#B}}", out) + } + + @Test + fun givenPromptText_whenRendering_thenItIsRenderedWithTheValuesInScope() { + val values = mapOf("A" to PromptText("use {{TOOL}}", "a"), "TOOL" to "respond") + + assertEquals("use respond", PromptTemplateEngine.render("{{A}}", values)) + } + + @Test + fun givenPromptTextInsideAList_whenRendering_thenItSeesTheItemsKeys() { + // How a module line in ide_context.yml names the module it is repeated for. + val values = + mapOf( + "LINE" to PromptText("module {{NAME}}", "line"), + "MODULES" to listOf(mapOf("NAME" to "app"), mapOf("NAME" to "lib")), + ) + + val out = PromptTemplateEngine.render("{{#MODULES}}[{{LINE}}]{{/MODULES}}", values) + + assertEquals("[module app][module lib]", out) + } + + @Test + fun givenATypoInPromptText_whenRendering_thenTheFailureNamesWhereTheTextCameFrom() { + val values = + mapOf( + "OUTER" to PromptText("{{INNER}}", "layout.yml: layout.system_prompt"), + "INNER" to PromptText("{{TERMINAL_TOLL}}", "rules.yml: rules[0].items[1]"), + ) + + val error = + assertThrows(IllegalArgumentException::class.java) { + PromptTemplateEngine.render(PromptText("{{OUTER}}", "root"), values) + } + + assertEquals("rules.yml: rules[0].items[1]: unknown name {{TERMINAL_TOLL}}", error.message) + } + + @Test + fun givenPromptTextThatNamesItself_whenRendering_thenItFailsInsteadOfLooping() { + val values = mapOf("LOOP" to PromptText("{{LOOP}}", "loop")) + + assertThrows(IllegalArgumentException::class.java) { + PromptTemplateEngine.render("{{LOOP}}", values) + } + } + + @Test + fun givenRegexReplacementCharacters_whenRendering_thenTheyAreInsertedLiterally() { + val out = PromptTemplateEngine.render("{{A}}", mapOf("A" to """$1 \n""")) + + assertEquals("""$1 \n""", out) + } + + @Test + fun givenAnUnknownName_whenRendering_thenItFailsNamingIt() { + val error = + assertThrows(IllegalArgumentException::class.java) { + PromptTemplateEngine.render("{{A}} {{MISSING}}", mapOf("A" to "x")) + } + + assertEquals("unknown name {{MISSING}}", error.message) + } + + @Test + fun givenCamelCaseAndLowerCaseNames_whenRendering_thenTheyRenderLikeUpperCaseOnes() { + val values = + mapOf( + "fileName" to "a.kt", + "items" to listOf(mapOf("name" to "x"), mapOf("name" to "y")), + "none" to false, + ) + + val out = PromptTemplateEngine.render("{{fileName}}:{{#items}} {{name}}{{/items}}{{^none}}!{{/none}}", values) + + assertEquals("a.kt: x y!", out) + } + + @Test + fun givenADottedName_whenRendering_thenItIsATag() { + val values = mapOf("item.name" to "a.kt", "file.list" to listOf(mapOf("x" to "1"))) + + val out = PromptTemplateEngine.render("{{item.name}}{{#file.list}} {{x}}{{/file.list}}", values) + + assertEquals("a.kt 1", out) + } + + @Test + fun givenANullValue_whenRenderedAsText_thenItFails() { + // Null opens no section; printed as text it would be a hole in the prompt. + assertThrows(IllegalArgumentException::class.java) { + PromptTemplateEngine.render("{{A}}", mapOf("A" to null)) + } + } + + @Test + fun givenABoolean_whenRenderedAsText_thenItFails() { + // A flag is for a section; printed it would put "true" in the prompt. + assertThrows(IllegalArgumentException::class.java) { + PromptTemplateEngine.render("{{A}}", mapOf("A" to true)) + } + } + + @Test + fun givenJsonBraces_whenRendering_thenOnlyTagsAreTouched() { + // The tool-call example ends in `"}}`, which must not be read as a tag. + val template = """{"args":{"file_path":"{{PATH}}"}}""" + + val out = PromptTemplateEngine.render(template, mapOf("PATH" to "a.kt")) + + assertEquals("""{"args":{"file_path":"a.kt"}}""", out) + } + + @Test + fun givenAList_whenRenderingASection_thenItRepeatsPerItemWithTheItemsKeys() { + val values = + mapOf( + "TOOLS" to listOf(mapOf("NAME" to "a"), mapOf("NAME" to "b")), + "NAME" to "outer", + ) + + val out = PromptTemplateEngine.render("{{#TOOLS}}[{{NAME}}]{{/TOOLS}} {{NAME}}", values) + + assertEquals("[a][b] outer", out) + } + + @Test + fun givenAnItemWithoutAKey_whenRenderingASection_thenTheOuterValueIsUsed() { + val values = mapOf("ITEMS" to listOf(mapOf("X" to "1")), "TOOL" to "respond") + + val out = PromptTemplateEngine.render("{{#ITEMS}}{{X}} {{TOOL}}{{/ITEMS}}", values) + + assertEquals("1 respond", out) + } + + @Test + fun givenAList_whenRenderingASection_thenFirstAndLastMarkEachItemsPosition() { + // What lets a layout separate groups without a trailing separator. + val values = mapOf("L" to listOf(mapOf("X" to "a"), mapOf("X" to "b"), mapOf("X" to "c"))) + + val out = PromptTemplateEngine.render("{{#L}}{{^FIRST}}, {{/FIRST}}{{X}}{{#LAST}}.{{/LAST}}{{/L}}", values) + + assertEquals("a, b, c.", out) + } + + @Test + fun givenFalsyValues_whenRenderingSections_thenEachDropsOut() { + val values = mapOf("N" to null, "F" to false, "E" to "", "L" to emptyList()) + + val out = PromptTemplateEngine.render("a{{#N}}n{{/N}}{{#F}}f{{/F}}{{#E}}e{{/E}}{{#L}}l{{/L}}b", values) + + assertEquals("ab", out) + } + + @Test + fun givenFalsyValues_whenRenderingInvertedSections_thenEachRendersOnce() { + val values = mapOf("N" to null, "F" to false, "E" to "", "L" to emptyList()) + + val out = PromptTemplateEngine.render("{{^N}}n{{/N}}{{^F}}f{{/F}}{{^E}}e{{/E}}{{^L}}l{{/L}}", values) + + assertEquals("nfel", out) + } + + @Test + fun givenTruthyValues_whenRenderingSections_thenEachRendersOnceAndTheInvertedOnesDropOut() { + val values = mapOf("T" to true, "S" to "x", "P" to PromptText("p", "p")) + + val out = PromptTemplateEngine.render("{{#T}}t{{/T}}{{#S}}{{S}}{{/S}}{{#P}}p{{/P}}{{^T}}no{{/T}}", values) + + assertEquals("txp", out) + } + + @Test + fun givenSectionTagsOnTheirOwnLines_whenRendering_thenThoseLinesLeaveNoTrace() { + // What lets a layout put one tag per line without blank lines in the prompt. + val template = "Tools:\n{{#TOOLS}}\n- {{NAME}}\n{{/TOOLS}}\nend" + val values = mapOf("TOOLS" to listOf(mapOf("NAME" to "a"), mapOf("NAME" to "b"))) + + assertEquals("Tools:\n- a\n- b\nend", PromptTemplateEngine.render(template, values)) + } + + @Test + fun givenAClosedSectionOnItsOwnLines_whenRendering_thenNoBlankLineIsLeft() { + val template = "a\n {{#X}} \nx\n{{/X}}\nb" + + assertEquals("a\nb", PromptTemplateEngine.render(template, mapOf("X" to false))) + } + + @Test + fun givenASectionTagSharingItsLine_whenRendering_thenTheLineIsKept() { + val template = "a {{#X}}x{{/X}}\nb" + + assertEquals("a x\nb", PromptTemplateEngine.render(template, mapOf("X" to true))) + } + + @Test + fun givenAnUnclosedOrMismatchedSection_whenRendering_thenItFails() { + val values = mapOf("A" to true, "B" to true) + + assertThrows(IllegalArgumentException::class.java) { + PromptTemplateEngine.render("{{#A}}x", values) + } + assertThrows(IllegalArgumentException::class.java) { + PromptTemplateEngine.render("{{^A}}x{{/B}}", values) + } + } +} From 45bc1b1b1139be550f6cb912481807b5ecf1a0fd Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 30 Sep 2026 12:25:26 -0500 Subject: [PATCH 2/6] fix(plugin-api): ADFA-6281 address review on prompt engine and pane helpers Accept padded tags and reject malformed ones, re-mask on reattach, floor changelog at 26.41. --- docs/PLUGIN_API_CHANGELOG.md | 7 +++--- .../plugins/ai/prompt/PromptTemplateEngine.kt | 22 ++++++++++++++----- .../androidide/plugins/ai/ui/PaneStyling.kt | 3 ++- .../plugins/ai/ui/SecretRevealController.kt | 2 +- .../ai/prompt/PromptTemplateEngineTest.kt | 17 ++++++++++++++ 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index de251496f2..65493a79ce 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -35,7 +35,7 @@ need a source change, a recompile, or both · `tooling` = API-stability milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed]** = diffed from `plugin-api/src` history (predates the dump; symbol-accurate). -### 26.40 — unreleased +### 26.41 — unreleased - **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 @@ -63,7 +63,7 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed] AI-Core and the Gemini, Local and OpenAI agents now load and render their prompt config through `ai.prompt` and drop their private copies, and the Gemini, OpenAI and MCP settings screens use `ai.ui`. Floor - `plugin.min_ide_version` at `26.40` to use any of it; an older IDE has none of these + `plugin.min_ide_version` at `26.41` to use any of it; an older IDE has none of these classes, and the plugin fails with `NoClassDefFoundError` on first use. - **added — Read-only App Logs and IDE Logs** _(ADFA-6267)_ **[verified]** Plugins could read build output (`IdeBuildService.getBuildOutput()`) but not the App Logs @@ -79,7 +79,8 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed] `LogReadResult.EMPTY`, never a throw. The service has no clear or write method, and needs no permission: plugins run in-process under the IDE's uid, so a gate would disclose log access, not enforce it. Purely additive (the ABI dump diff is additions only). Floor - `plugin.min_ide_version` at `26.40` to use it; an older IDE has no such service. + `plugin.min_ide_version` at `26.41` to use it; an older IDE has no such service. +### 26.40 — 2026-09-29 - **added — An embedding capability a backend can declare** _(ADFA-6053)_ **[verified]** A backend that has an embedding model can now say so. The only embedding entry point before this was `LlmInferenceService.getEmbeddings(String, String)`, which addresses a diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine.kt index feb6d312a1..a72acce3f5 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine.kt @@ -3,7 +3,8 @@ package com.itsaky.androidide.plugins.ai.prompt /** * Renders a prompt template: `{{NAME}}` values, `{{#NAME}}...{{/NAME}}` and `{{^NAME}}...{{/NAME}}` * sections; a name is a letter then letters, digits, `_` or `.`, in any case, e.g. `{{FILE_NAME}}`, - * `{{fileName}}` or `{{item.name}}`, looked up as the whole key. Strict, so a typo throws; a String value is never rescanned, a [PromptText] always is. + * `{{fileName}}` or `{{item.name}}`, looked up as the whole key; padding such as `{{ NAME }}` is allowed. + * Strict, so a typo or a malformed tag throws; a String value is never rescanned, a [PromptText] always is. * Stateless and free of reflection, so it is safe to call from any thread. */ object PromptTemplateEngine { @@ -13,7 +14,10 @@ object PromptTemplateEngine { /** Set in each list item's scope: whether it is the list's last item. */ const val LAST = "LAST" - private val TAG = Regex("""\{\{(?:([#^/])([a-zA-Z][a-zA-Z0-9_.]*)|([a-zA-Z][a-zA-Z0-9_.]*))\}\}""") + private val TAG = Regex("""\{\{\s*(?:([#^/])\s*([a-zA-Z][a-zA-Z0-9_.]*)|([a-zA-Z][a-zA-Z0-9_.]*))\s*\}\}""") + + /** The start of a tag [TAG] did not match, e.g. `{{file-name}}`. */ + private val MALFORMED = Regex("""\{\{\s*(?:[#^/]\s*)?[a-zA-Z]""") /** Deep enough for any sane nesting of [PromptText]; stops one that names itself. */ private const val MAX_DEPTH = 8 @@ -169,11 +173,17 @@ object PromptTemplateEngine { // Each open section: its opening tag and the nodes collected for it so far. val open = ArrayDeque>>() + var position = 0 + fun current() = open.lastOrNull()?.second ?: root - var position = 0 + fun addText(end: Int) { + val text = template.substring(position, end) + MALFORMED.find(text)?.let { throw IllegalArgumentException("malformed tag at '${it.value}'") } + current().add(Node.Text(text)) + } + for (match in TAG.findAll(template)) { - if (match.range.first < position) continue val (marker, sectionName, valueName) = match.destructured var textEnd = match.range.first var next = match.range.last + 1 @@ -183,7 +193,7 @@ object PromptTemplateEngine { next = end } } - if (textEnd > position) current().add(Node.Text(template.substring(position, textEnd))) + if (textEnd > position) addText(textEnd) position = next when (marker) { @@ -206,7 +216,7 @@ object PromptTemplateEngine { } } require(open.isEmpty()) { "{{#${open.last().first.name}}} is never closed" } - if (position < template.length) root.add(Node.Text(template.substring(position))) + if (position < template.length) addText(template.length) return root } diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/PaneStyling.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/PaneStyling.kt index 485168364f..6cd59a375b 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/PaneStyling.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/PaneStyling.kt @@ -31,7 +31,8 @@ data class ButtonColors( /** * An outlined-box text field, from the plugin's own colour resources. * - * @property stroke the outline + * @property stroke the outline; a selector with a colour per state, since a plain colour recolours + * only the focused outline * @property error the outline while the field shows an error * @property hint the hint * @property endIcon the end icon's tint diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/SecretRevealController.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/SecretRevealController.kt index b4a8780745..ec47ce4e95 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/SecretRevealController.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/SecretRevealController.kt @@ -67,7 +67,7 @@ class SecretRevealController( // description carries the state instead. box.isEndIconCheckable = false box.setEndIconOnClickListener { isRevealed = !isRevealed } - render() + if (isRevealed) mask() else render() } /** diff --git a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngineTest.kt b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngineTest.kt index 7246db8fc5..d395537647 100644 --- a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngineTest.kt +++ b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngineTest.kt @@ -107,6 +107,23 @@ class PromptTemplateEngineTest { assertEquals("a.kt 1", out) } + @Test + fun givenPaddedTags_whenRendering_thenTheyAreTags() { + val out = PromptTemplateEngine.render("{{ A }}{{# B }}!{{/B }}", mapOf("A" to "x", "B" to true)) + + assertEquals("x!", out) + } + + @Test + fun givenAMalformedTag_whenRendering_thenItFails() { + // Emitted verbatim, it would reach the model as a literal tag with nothing reporting it. + for (template in listOf("{{file-name}}", "{{ A }", "{{# file-list}}")) { + assertThrows(template, IllegalArgumentException::class.java) { + PromptTemplateEngine.render(template, emptyMap()) + } + } + } + @Test fun givenANullValue_whenRenderedAsText_thenItFails() { // Null opens no section; printed as text it would be a hole in the prompt. From 2138ae4b4e0edaf987510fdb398a2e70066a2a2e Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 30 Sep 2026 12:45:04 -0500 Subject: [PATCH 3/6] feat(plugin-api): ADFA-6281 add WebSearchBackend for live web search capability Backends implement canSearchWeb() so ai-core offers web_search only when a search would run. --- docs/PLUGIN_API_CHANGELOG.md | 6 ++++-- plugin-api/api/plugin-api.api | 4 ++++ .../plugins/services/LlmInferenceService.java | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 65493a79ce..1d402e9a03 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -59,10 +59,12 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed] `ButtonColors` per emphasis and a `FieldColors`. Both take the plugin's own resource ids rather than shipping any: they resolve against the view's context, which carries the plugin's resources, not the host's. - Additive to the ABI (178 added lines in the dump, none removed), but no longer unused: + Additive to the ABI (182 added lines in the dump, none removed), but no longer unused: AI-Core and the Gemini, Local and OpenAI agents now load and render their prompt config through `ai.prompt` and drop their private copies, and the Gemini, OpenAI and MCP - settings screens use `ai.ui`. Floor + settings screens use `ai.ui`. `LlmInferenceService.WebSearchBackend` (`canSearchWeb()`) + lets a backend say whether a `web_search` request would be searched now; ai-core forces + and offers its `web_search` tool only when it does. Floor `plugin.min_ide_version` at `26.41` to use any of it; an older IDE has none of these classes, and the plugin fails with `NoClassDefFoundError` on first use. - **added — Read-only App Logs and IDE Logs** _(ADFA-6267)_ **[verified]** diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index c4c96c3b0b..178000eff9 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -1868,6 +1868,10 @@ public abstract interface class com/itsaky/androidide/plugins/services/LlmInfere public abstract fun onToolCall (Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ToolCallRequest;)V } +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$WebSearchBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public abstract fun canSearchWeb ()Z +} + public final class com/itsaky/androidide/plugins/services/LogEntry { public fun (Lcom/itsaky/androidide/plugins/services/LogLevel;Ljava/lang/String;)V public final fun component1 ()Lcom/itsaky/androidide/plugins/services/LogLevel; 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 0a34a333b4..8701b6e8e7 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 @@ -772,4 +772,19 @@ interface ToolStreamCallback { */ void onToolCall(ToolCallRequest request); } + + /** + * An {@link LlmBackend} that can answer a request from a live web search, sent with {@code "web_search" = true} in {@link LlmConfig#extraParams}. + * + *

+ * The answer can change with the backend's settings (an OpenAI-compatible server may not search), so the consumer asks before each run rather than once. + */ + interface WebSearchBackend extends LlmBackend { + /** + * Whether a web search request sent now would be searched rather than refused. + * + * @return true when the backend's current configuration can search the web + */ + boolean canSearchWeb(); + } } From 5bed37e4ef14f8e267b21ead1dfee5e1aa4c4e88 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 30 Sep 2026 14:15:29 -0500 Subject: [PATCH 4/6] feat(plugin-api): ADFA-6281 add PromptConfigStore.reload Owns the load scope and reports load/failure, so plugins drop their copied lifecycle code. --- docs/PLUGIN_API_CHANGELOG.md | 5 ++- plugin-api/api/plugin-api.api | 1 + .../plugins/ai/prompt/PromptConfigStore.kt | 37 ++++++++++++++++++- .../ai/prompt/PromptConfigStoreTest.kt | 32 ++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 1d402e9a03..ad9cdc1819 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -51,7 +51,8 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed] `PromptConfigException`; and `PromptConfigStore`, the per-activation cache, behind `PromptConfigProvider`. Loader and store are generic over the plugin's config type: a plugin supplies only a `PromptConfigParser` and keeps one store, e.g. - `val shared = PromptConfigStore(MyParser)`. The YAML library (snakeyaml-engine 2.10) + `val shared = PromptConfigStore(MyParser)`, and calls `shared.reload(source, onLoaded, onFailed)` + from `activate()` and `shared.clear()` from `deactivate()`. The YAML library (snakeyaml-engine 2.10) is on the host side, so a plugin using the loader no longer bundles it. `com.itsaky.androidide.plugins.ai.ui`: `SecretRevealController`, whose two states are each a `RevealToggle` (icon and content description), and @@ -59,7 +60,7 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed] `ButtonColors` per emphasis and a `FieldColors`. Both take the plugin's own resource ids rather than shipping any: they resolve against the view's context, which carries the plugin's resources, not the host's. - Additive to the ABI (182 added lines in the dump, none removed), but no longer unused: + Additive to the ABI (183 added lines in the dump, none removed), but no longer unused: AI-Core and the Gemini, Local and OpenAI agents now load and render their prompt config through `ai.prompt` and drop their private copies, and the Gemini, OpenAI and MCP settings screens use `ai.ui`. `LlmInferenceService.WebSearchBackend` (`canSearchWeb()`) diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index 178000eff9..0f46de2ea2 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -205,6 +205,7 @@ public final class com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore : c public fun config (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun configIfLoaded ()Ljava/lang/Object; public final fun preload (Lkotlinx/coroutines/CoroutineScope;Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigSource;)Lkotlinx/coroutines/Deferred; + public final fun reload (Lcom/itsaky/androidide/plugins/ai/prompt/PromptConfigSource;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/Deferred; } public final class com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine { diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore.kt index ffce65ac0d..1b71afad55 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore.kt @@ -1,10 +1,14 @@ package com.itsaky.androidide.plugins.ai.prompt +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async +import kotlinx.coroutines.cancel import java.util.concurrent.atomic.AtomicReference /** Supplies the loaded prompt config, suspending until it is available. */ @@ -30,6 +34,36 @@ class PromptConfigStore( private val parser: PromptConfigParser, ) : PromptConfigProvider { private val load = AtomicReference?>(null) + private val ownScope = AtomicReference(null) + + /** + * Drops any cached config and loads from [source] afresh, in a scope this store owns until [clear]. + * A plugin calls it from `activate()`; a load cancelled by [clear] reports to neither callback. + * + * @param source where the config file lives. + * @param onLoaded given the config once it loads, e.g. to log checks against it. + * @param onFailed given the error when the load fails. + * @return the load now cached. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun reload( + source: PromptConfigSource, + onLoaded: (T) -> Unit, + onFailed: (Throwable) -> Unit, + ): Deferred { + clear() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + ownScope.getAndSet(scope)?.cancel() + val next = preload(scope, source) + next.invokeOnCompletion { error -> + when (error) { + null -> onLoaded(next.getCompleted()) + is CancellationException -> Unit + else -> onFailed(error) + } + } + return next + } /** * Starts loading from [source] unless a load is already cached; a failed load is retried. @@ -81,9 +115,10 @@ class PromptConfigStore( return if (current.isCompleted && !current.hasFailed()) current.getCompleted() else null } - /** Drops the cache, cancelling a load still in flight; the next [preload] reads afresh. */ + /** Drops the cache, cancelling a load still in flight and [reload]'s scope; idempotent. */ fun clear() { load.getAndSet(null)?.cancel() + ownScope.getAndSet(null)?.cancel() } /** diff --git a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStoreTest.kt b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStoreTest.kt index fd9175262e..65cb1341e7 100644 --- a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStoreTest.kt +++ b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStoreTest.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertThrows @@ -135,5 +136,36 @@ class PromptConfigStoreTest { assertNull(store.configIfLoaded()) } + @Test + fun givenAReload_whenTheLoadSucceeds_thenOnLoadedGetsTheConfig() { + val reported = CompletableDeferred() + + store.reload(CountingSource(), { reported.complete(it.identity.label) }, { reported.completeExceptionally(it) }) + + assertEquals("agent.yml: identity", runBlocking { reported.await() }) + } + + @Test + fun givenAReload_whenTheLoadFails_thenOnFailedGetsTheError() { + val reported = CompletableDeferred() + + store.reload(CountingSource(fail = true), { reported.cancel() }, { reported.complete(it) }) + + runBlocking { reported.await() } + } + + @Test + fun givenAReload_whenClearedMidLoad_thenNeitherCallbackRuns() { + val gate = CompletableDeferred() + var reported = false + val load = store.reload(gatedSource(gate), { reported = true }, { reported = true }) + + store.clear() + gate.complete(Unit) + runBlocking { load.join() } + + assertFalse(reported) + } + private fun Deferred<*>.settle() = runBlocking { runCatching { await() } } } From 0393f5b9d3964af002f63401c035fe38a7016e57 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 1 Oct 2026 08:46:25 -0500 Subject: [PATCH 5/6] fix(plugin-api): ADFA-6281 address second review round - PromptConfigStore.reload: catch and log a throwing callback instead of letting it reach the uncaught handler; document the callback thread; serialize reload and clear. - PromptTemplateEngine: `{{{{` renders a literal `{{`. - SecretRevealController: onLegibleChanged KDoc covers re-attach. - LlmInferenceService: list WebSearchBackend among the capabilities. --- docs/PLUGIN_API_CHANGELOG.md | 2 +- .../plugins/services/LlmInferenceService.java | 2 +- .../plugins/ai/prompt/PromptConfigStore.kt | 22 +++++++++++++++---- .../plugins/ai/prompt/PromptTemplateEngine.kt | 11 +++++++++- .../plugins/ai/ui/SecretRevealController.kt | 3 ++- .../ai/prompt/PromptConfigStoreTest.kt | 15 +++++++++++++ .../ai/prompt/PromptTemplateEngineTest.kt | 7 ++++++ 7 files changed, 54 insertions(+), 8 deletions(-) diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index ad9cdc1819..5100529964 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -43,7 +43,7 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed] drift. The host now ships one copy. `com.itsaky.androidide.plugins.ai.prompt`: `PromptTemplateEngine` and `PromptText` (the `{{NAME}}` / `{{#NAME}}` / `{{^NAME}}` renderer; names may be in any case and hold - dots, e.g. `{{fileName}}` or `{{item.name}}`, and config text keeps its whitespace as YAML + dots, e.g. `{{fileName}}` or `{{item.name}}`, `{{{{` writes a literal `{{`, and config text keeps its whitespace as YAML parsed it); `PromptConfigLoader.load(source, parser)`, which reads `agent.yml` and its `include` list off the main thread; `PromptConfigDocument` and `PromptConfigObject`, the strict key-by-key reader a parser 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 8701b6e8e7..bda9e01573 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,7 +13,7 @@ * 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} -- 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 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. */ public interface LlmInferenceService { diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore.kt index 1b71afad55..27e10433f6 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStore.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import org.slf4j.LoggerFactory import java.util.concurrent.atomic.AtomicReference /** Supplies the loaded prompt config, suspending until it is available. */ @@ -39,6 +40,8 @@ class PromptConfigStore( /** * Drops any cached config and loads from [source] afresh, in a scope this store owns until [clear]. * A plugin calls it from `activate()`; a load cancelled by [clear] reports to neither callback. + * The callbacks run on the thread that completes the load, or on the caller's if it already has, + * never the main thread by guarantee; a callback that throws is logged, not rethrown. * * @param source where the config file lives. * @param onLoaded given the config once it loads, e.g. to log checks against it. @@ -46,6 +49,7 @@ class PromptConfigStore( * @return the load now cached. */ @OptIn(ExperimentalCoroutinesApi::class) + @Synchronized fun reload( source: PromptConfigSource, onLoaded: (T) -> Unit, @@ -56,10 +60,15 @@ class PromptConfigStore( ownScope.getAndSet(scope)?.cancel() val next = preload(scope, source) next.invokeOnCompletion { error -> - when (error) { - null -> onLoaded(next.getCompleted()) - is CancellationException -> Unit - else -> onFailed(error) + // Thrown out of a completion handler, it would reach the uncaught handler and end the IDE. + try { + when (error) { + null -> onLoaded(next.getCompleted()) + is CancellationException -> Unit + else -> onFailed(error) + } + } catch (e: Exception) { + log.error("prompt config reload callback failed", e) } } return next @@ -116,6 +125,7 @@ class PromptConfigStore( } /** Drops the cache, cancelling a load still in flight and [reload]'s scope; idempotent. */ + @Synchronized fun clear() { load.getAndSet(null)?.cancel() ownScope.getAndSet(null)?.cancel() @@ -127,4 +137,8 @@ class PromptConfigStore( */ @OptIn(ExperimentalCoroutinesApi::class) private fun Deferred<*>.hasFailed(): Boolean = isCancelled || (isCompleted && getCompletionExceptionOrNull() != null) + + private companion object { + private val log = LoggerFactory.getLogger(PromptConfigStore::class.java) + } } diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine.kt index a72acce3f5..b4dc3bf7dc 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngine.kt @@ -4,6 +4,7 @@ package com.itsaky.androidide.plugins.ai.prompt * Renders a prompt template: `{{NAME}}` values, `{{#NAME}}...{{/NAME}}` and `{{^NAME}}...{{/NAME}}` * sections; a name is a letter then letters, digits, `_` or `.`, in any case, e.g. `{{FILE_NAME}}`, * `{{fileName}}` or `{{item.name}}`, looked up as the whole key; padding such as `{{ NAME }}` is allowed. + * `{{{{` renders a literal `{{`, e.g. `{{{{it}}` renders `{{it}}`. * Strict, so a typo or a malformed tag throws; a String value is never rescanned, a [PromptText] always is. * Stateless and free of reflection, so it is safe to call from any thread. */ @@ -14,7 +15,9 @@ object PromptTemplateEngine { /** Set in each list item's scope: whether it is the list's last item. */ const val LAST = "LAST" - private val TAG = Regex("""\{\{\s*(?:([#^/])\s*([a-zA-Z][a-zA-Z0-9_.]*)|([a-zA-Z][a-zA-Z0-9_.]*))\s*\}\}""") + private const val ESCAPE = "{{{{" + + private val TAG = Regex("""\{\{\{\{|\{\{\s*(?:([#^/])\s*([a-zA-Z][a-zA-Z0-9_.]*)|([a-zA-Z][a-zA-Z0-9_.]*))\s*\}\}""") /** The start of a tag [TAG] did not match, e.g. `{{file-name}}`. */ private val MALFORMED = Regex("""\{\{\s*(?:[#^/]\s*)?[a-zA-Z]""") @@ -184,6 +187,12 @@ object PromptTemplateEngine { } for (match in TAG.findAll(template)) { + if (match.value == ESCAPE) { + if (match.range.first > position) addText(match.range.first) + current().add(Node.Text("{{")) + position = match.range.last + 1 + continue + } val (marker, sectionName, valueName) = match.destructured var textEnd = match.range.first var next = match.range.last + 1 diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/SecretRevealController.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/SecretRevealController.kt index ec47ce4e95..26deea136b 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/SecretRevealController.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/ai/ui/SecretRevealController.kt @@ -36,7 +36,8 @@ data class RevealToggle( * @param hide what the control shows while the secret stands in clear text * @param onLegibleChanged called with true when the secret comes to stand in clear text and false once * it is masked again, so the caller can flag its window secure - which window that is depends on - * the screen, not on this control. Not called by [attach], which leaves the secret masked. + * the screen, not on this control. Not called by a first [attach]; re-attaching while revealed + * masks the secret and reports false. */ @MainThread class SecretRevealController( diff --git a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStoreTest.kt b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStoreTest.kt index 65cb1341e7..2a4e7663b7 100644 --- a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStoreTest.kt +++ b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptConfigStoreTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -167,5 +168,19 @@ class PromptConfigStoreTest { assertFalse(reported) } + @Test + fun givenACallbackThatThrows_whenTheLoadCompletes_thenNothingReachesTheUncaughtHandler() { + val uncaught = CompletableDeferred() + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { _, e -> uncaught.complete(e) } + try { + store.reload(CountingSource(), { error("from onLoaded") }, {}).settle() + + assertNull(runBlocking { withTimeoutOrNull(1_000) { uncaught.await() } }) + } finally { + Thread.setDefaultUncaughtExceptionHandler(previous) + } + } + private fun Deferred<*>.settle() = runBlocking { runCatching { await() } } } diff --git a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngineTest.kt b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngineTest.kt index d395537647..50a62f6ae8 100644 --- a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngineTest.kt +++ b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/ai/prompt/PromptTemplateEngineTest.kt @@ -124,6 +124,13 @@ class PromptTemplateEngineTest { } } + @Test + fun givenAnEscapedBrace_whenRendering_thenItIsALiteralBrace() { + val out = PromptTemplateEngine.render("Row {{{{it}} {{A}}", mapOf("A" to "x")) + + assertEquals("Row {{it}} x", out) + } + @Test fun givenANullValue_whenRenderedAsText_thenItFails() { // Null opens no section; printed as text it would be a hole in the prompt. From 272d18f473eb69779928e5e5a98bfa14ea26dadc Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 1 Oct 2026 09:31:40 -0500 Subject: [PATCH 6/6] feat(plugin-api): define web_search and required_tool extraParams keys Refs: ADFA-6281 --- docs/PLUGIN_API_CHANGELOG.md | 6 ++++-- plugin-api/api/plugin-api.api | 2 ++ .../plugins/services/LlmInferenceService.java | 12 +++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 5100529964..708b80164b 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -60,12 +60,14 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed] `ButtonColors` per emphasis and a `FieldColors`. Both take the plugin's own resource ids rather than shipping any: they resolve against the view's context, which carries the plugin's resources, not the host's. - Additive to the ABI (183 added lines in the dump, none removed), but no longer unused: + Additive to the ABI (185 added lines in the dump, none removed), but no longer unused: AI-Core and the Gemini, Local and OpenAI agents now load and render their prompt config through `ai.prompt` and drop their private copies, and the Gemini, OpenAI and MCP settings screens use `ai.ui`. `LlmInferenceService.WebSearchBackend` (`canSearchWeb()`) lets a backend say whether a `web_search` request would be searched now; ai-core forces - and offers its `web_search` tool only when it does. Floor + and offers its `web_search` tool only when it does. The `extraParams` keys both sides + read are defined once, as `WebSearchBackend.EXTRA_PARAM_WEB_SEARCH` and + `ToolCallingBackend.EXTRA_PARAM_REQUIRED_TOOL`. Floor `plugin.min_ide_version` at `26.41` to use any of it; an older IDE has none of these classes, and the plugin fails with `NoClassDefFoundError` on first use. - **added — Read-only App Logs and IDE Logs** _(ADFA-6267)_ **[verified]** diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index 0f46de2ea2..d710d3f2bc 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -1852,6 +1852,7 @@ public class com/itsaky/androidide/plugins/services/LlmInferenceService$ToolCall } public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$ToolCallingBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public static final field EXTRA_PARAM_REQUIRED_TOOL Ljava/lang/String; public abstract fun generateStreamingWithTools (Ljava/lang/String;Ljava/util/List;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;Ljava/util/List;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ToolStreamCallback;)V } @@ -1870,6 +1871,7 @@ public abstract interface class com/itsaky/androidide/plugins/services/LlmInfere } public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$WebSearchBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public static final field EXTRA_PARAM_WEB_SEARCH Ljava/lang/String; public abstract fun canSearchWeb ()Z } 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 bda9e01573..54a50a652d 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 @@ -641,6 +641,11 @@ public SystemPromptRequest(@Nullable List tools, @Nullable Strin * Implementing this is the declaration, and it means {@link ToolStreamCallback#onToolCall} will fire for a call the model makes. A backend that merely wants earlier turns implements {@link HistoryCapableBackend} instead: accepting tools and never calling one leaves the consumer waiting on an action the model was never able to take. */ interface ToolCallingBackend extends LlmBackend { + /** + * {@link LlmConfig#extraParams} key naming the one declared tool the model must call this turn. A backend that cannot force a call ignores it. + */ + String EXTRA_PARAM_REQUIRED_TOOL = "required_tool"; + /** * Generates a completion with streaming output and tool calling support. * @@ -774,12 +779,17 @@ interface ToolStreamCallback { } /** - * An {@link LlmBackend} that can answer a request from a live web search, sent with {@code "web_search" = true} in {@link LlmConfig#extraParams}. + * An {@link LlmBackend} that can answer a request from a live web search, sent with {@link #EXTRA_PARAM_WEB_SEARCH} {@code = true} in {@link LlmConfig#extraParams}. * *

* The answer can change with the backend's settings (an OpenAI-compatible server may not search), so the consumer asks before each run rather than once. */ interface WebSearchBackend extends LlmBackend { + /** + * {@link LlmConfig#extraParams} key asking a backend to answer from a web search. + */ + String EXTRA_PARAM_WEB_SEARCH = "web_search"; + /** * Whether a web search request sent now would be searched rather than refused. *