diff --git a/ai-agent-local/BUILDING.md b/ai-agent-local/BUILDING.md index 7d31f739..c49d83a9 100644 --- a/ai-agent-local/BUILDING.md +++ b/ai-agent-local/BUILDING.md @@ -45,8 +45,14 @@ ndk.dir=/Users/your-username/Library/Android/sdk/ndk/27.0.12077973 That's the only setup a normal build needs. The native llama.cpp library is already committed as `ai-agent-local/libs/v8/llama-v8-release.aar` (the JNI -wrapper plus `.so` files for arm64-v8a, armeabi-v7a, x86 and x86_64), with its -tiny interface jar at `ai-agent-local/libs/llama-api.jar`. +wrapper plus the `.so` files), with its tiny interface jar at +`ai-agent-local/libs/llama-api.jar`. + +The plugin packages **arm64-v8a only**. The IDE extracts a single ABI +(`PluginLoader.extractNativeLibs` reads `lib/${Build.SUPPORTED_ABIS[0]}/`) and +CoGo itself ships no x86 build, so any other ABI would be download weight no +device can execute. `assemblePlugin` checks the packaged `.cgp` as its last step +and fails the build if it carries anything else. --- @@ -141,8 +147,8 @@ normal build. ### Native library not found (`UnsatisfiedLinkError`) -The prebuilt AAR bundles arm64-v8a, armeabi-v7a, x86 and x86_64. If you rebuilt -it with a restricted ABI set, confirm your device's ABI is included: +The plugin packages arm64-v8a only, so it needs a 64-bit ARM device. Confirm +what the AAR carries: ```bash unzip -l ai-agent-local/libs/v8/llama-v8-release.aar | grep 'jni/' @@ -366,13 +372,12 @@ jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \ ### Size Optimization Current release sizes: -- **ai-agent-local**: ~5 MB (includes llama.cpp native libraries) +- **ai-agent-local**: ~15 MB (includes the arm64-v8a llama.cpp native libraries) - **ai-assistant-plugin**: ~2 MB (UI and business logic) -To reduce size: -1. Build only arm64-v8a ABI (skip v7/x86) -2. Enable R8 full mode in `gradle.properties` -3. Strip debug symbols from native libraries +To reduce size further: +1. Enable R8 full mode in `gradle.properties` +2. Strip debug symbols from native libraries --- @@ -380,7 +385,8 @@ To reduce size: When contributing changes to the AI plugins: -1. Test on **physical ARM64 device** (emulators may not support native code) +1. Test on a **physical ARM64 device** — the plugin packages arm64-v8a only, and + an x86_64 emulator cannot run CoGo at all (there is no x86 build of the IDE) 2. Verify **ai-core**, **ai-agent-local**, **ai-agent-gemini** and **ai-assistant** work together 3. Ensure **llama.cpp submodule** updates are documented 4. Run ProGuard/R8 release build to catch reflection issues diff --git a/ai-agent-local/README.md b/ai-agent-local/README.md index 69bb3e87..25334e06 100644 --- a/ai-agent-local/README.md +++ b/ai-agent-local/README.md @@ -6,7 +6,8 @@ what `ai-core`'s Agent chat, `code-suggestions-plugin`, `speech-to-text-plugin` `vector-search-plugin` actually talk to. Runs `.gguf` models through a bundled, prebuilt **llama.cpp** AAR. Declares no -INTERNET permission — prompts and code never leave the device. +INTERNET permission — prompts and code never leave the device. Requires a 64-bit +ARM device (`arm64-v8a`). ## Building diff --git a/ai-agent-local/ai-agent-local.html b/ai-agent-local/ai-agent-local.html index ba6092da..0ac3b8fc 100644 --- a/ai-agent-local/ai-agent-local.html +++ b/ai-agent-local/ai-agent-local.html @@ -96,9 +96,10 @@

Technical architecture

Core mounts: model browser, load-from-saved, SHA-256 verification and the prompt-format toggle, plus the low-memory warning dialog. -

The native library is committed prebuilt (four ABIs: arm64-v8a, - armeabi-v7a, x86, x86_64), so a normal build needs no NDK, CMake, or the - llama.cpp submodule.

+

The native library is committed prebuilt, so a normal build needs no NDK, + CMake, or the llama.cpp submodule. Packaging is limited to arm64-v8a: + the IDE extracts a single ABI and ships no x86 build, so this plugin requires a + 64-bit ARM device.

Usage

    diff --git a/ai-agent-local/build.gradle.kts b/ai-agent-local/build.gradle.kts index f2f6b57a..0b99ef6b 100644 --- a/ai-agent-local/build.gradle.kts +++ b/ai-agent-local/build.gradle.kts @@ -1,3 +1,8 @@ +import java.io.File +import java.util.Enumeration +import java.util.zip.ZipEntry +import java.util.zip.ZipFile + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") @@ -16,8 +21,12 @@ android { applicationId = "com.itsaky.androidide.plugins.aiagentlocal" minSdk = 33 targetSdk = 36 - versionCode = 1 - versionName = "1.0.0" + versionCode = 2 + versionName = "1.0.1" + + ndk { + abiFilters += listOf("arm64-v8a") + } } buildTypes { @@ -74,6 +83,78 @@ dependencies { testImplementation("io.mockk:mockk:1.13.8") } +// The one ABI this plugin ships. Shared by the packaging check and the unit tests. +val expectedAbi = "arm64-v8a" + +// Matched on the variant suffix rather than an exact name: plugin-builder owns the file name. +fun File.isPluginArtifactFor(isDebug: Boolean) = + name.endsWith(".cgp") && name.endsWith("-debug.cgp") == isDebug + +// Unit tests read the committed AAR; pass its path so they do not depend on the working directory. +tasks.withType().configureEach { + systemProperty("prebuiltAarPath", file("libs/v8/llama-v8-release.aar").absolutePath) + systemProperty("expectedAbi", expectedAbi) +} + +// Guards the abiFilters above: a regenerated AAR or a dropped filter would silently re-inflate the .cgp. +// The check runs as the assemble task's last action rather than as a finalizer: a finalizer executes even +// after a failed assemble, so it would bury the real compile error under a "no .cgp found" failure in CI. +// It stays inline rather than moving to an applied script: apply(from = "*.gradle.kts") crashes +// lintVitalAnalyzeRelease ("Cannot find a KaModule for the VirtualFile") with this AGP/lint version. +// plugin-builder creates these tasks untyped inside its own afterEvaluate, so the task name is the only +// handle; tasks.named fails loudly if that name changes, where a name filter would silently stop wiring. +afterEvaluate { + mapOf("assemblePlugin" to false, "assemblePluginDebug" to true).forEach { (assembleTaskName, isDebug) -> + // Captured as a provider and read inside the action, so no path resolves at configuration time. + val pluginDir = layout.buildDirectory.dir("plugin") + val abi = expectedAbi + + tasks.named(assembleTaskName) { + // Runs before plugin-builder copies the new artifact in, so the check below can only ever see + // this run's output. A leftover .cgp (an earlier build under a different pluginName, say) is + // released by CI verbatim, so dropping it here keeps the shipped set and the checked set equal. + doFirst { + pluginDir.get().asFile.listFiles() + ?.filter { it.isPluginArtifactFor(isDebug) } + ?.forEach { it.delete() } + } + + doLast { + val dir = pluginDir.get().asFile + val artifacts = dir.listFiles() + ?.filter { it.isPluginArtifactFor(isDebug) } + ?.sortedBy { it.name } + .orEmpty() + if (artifacts.isEmpty()) { + throw GradleException("$assembleTaskName produced no .cgp under $dir.") + } + + artifacts.forEach { cgp -> + val abis = sortedSetOf() + ZipFile(cgp).use { zip -> + val entries: Enumeration = zip.entries() + while (entries.hasMoreElements()) { + val entry: ZipEntry = entries.nextElement() + val name: String = entry.name + if (!entry.isDirectory && name.startsWith("lib/") && name.endsWith(".so")) { + abis.add(name.removePrefix("lib/").substringBefore('/')) + } + } + } + + if (abis != sortedSetOf(abi)) { + throw GradleException( + "${cgp.name} packages native libraries for $abis but must package exactly [$abi]. " + + "Check the ndk.abiFilters block in build.gradle.kts.", + ) + } + logger.lifecycle("${cgp.name}: native libraries limited to $abi") + } + } + } + } +} + // AAR metadata checks are disabled by convention for these application-as-library // plugins. The prebuilt llama .aar carries a "core library desugaring required" // flag, but this module's minSdk (33) makes desugaring unnecessary at runtime, diff --git a/ai-agent-local/libs/llama-api.jar b/ai-agent-local/libs/llama-api.jar index d25bb977..f4bec37c 100644 Binary files a/ai-agent-local/libs/llama-api.jar and b/ai-agent-local/libs/llama-api.jar differ diff --git a/ai-agent-local/libs/v8/llama-v8-release.aar b/ai-agent-local/libs/v8/llama-v8-release.aar index 06fb4742..1fc67318 100644 Binary files a/ai-agent-local/libs/v8/llama-v8-release.aar and b/ai-agent-local/libs/v8/llama-v8-release.aar differ diff --git a/ai-agent-local/llama-impl/build.gradle.kts b/ai-agent-local/llama-impl/build.gradle.kts index 7bb87ea1..cc519ae4 100644 --- a/ai-agent-local/llama-impl/build.gradle.kts +++ b/ai-agent-local/llama-impl/build.gradle.kts @@ -17,8 +17,7 @@ android { minSdk = 33 consumerProguardFiles("proguard-rules.pro") ndk { - // Add NDK properties if wanted, e.g. - // abiFilters += listOf("arm64-v8a") + abiFilters += listOf("arm64-v8a") } externalNativeBuild { cmake { diff --git a/ai-agent-local/src/main/assets/docs/index.html b/ai-agent-local/src/main/assets/docs/index.html index 3a0ef684..c25acac5 100644 --- a/ai-agent-local/src/main/assets/docs/index.html +++ b/ai-agent-local/src/main/assets/docs/index.html @@ -44,7 +44,7 @@

    AI Agent Local — On-Device Inference

    What it does

    • Runs a .gguf model through a bundled, prebuilt llama.cpp - library (arm64-v8a, armeabi-v7a, x86 and x86_64).
    • + library. Requires a 64-bit ARM device (arm64-v8a).
    • Streams tokens as they are generated, and stops early when you press Stop.
    • Needs no network access — this plugin declares no INTERNET diff --git a/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/packaging/PrebuiltAarAbiTest.kt b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/packaging/PrebuiltAarAbiTest.kt new file mode 100644 index 00000000..686cedbe --- /dev/null +++ b/ai-agent-local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/packaging/PrebuiltAarAbiTest.kt @@ -0,0 +1,84 @@ +package com.itsaky.androidide.plugins.aiagentlocal.packaging + +import java.io.File +import java.util.zip.ZipFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the committed llama.cpp AAR against a bad regeneration. + * The plugin filters packaging to one ABI, so the AAR losing that ABI - or a + * partial native build - would only surface as an UnsatisfiedLinkError on device. + */ +class PrebuiltAarAbiTest { + + // Gradle passes the AAR path; the relative fallback keeps an IDE test runner - which sets no + // system properties but does run from the module directory - reporting a real assertion. + private val aar: File = File(System.getProperty("prebuiltAarPath") ?: AAR_RELATIVE_PATH) + + private val expectedAbi: String = System.getProperty("expectedAbi") ?: DEFAULT_ABI + + // One pass over the archive; every test reads this map instead of reopening it. + private val jniLibs: Map by lazy { + ZipFile(aar).use { zip -> + zip.entries() + .asSequence() + .filter { !it.isDirectory && it.name.startsWith("jni/") && it.name.endsWith(".so") } + .associate { it.name to it.size } + } + } + + private fun abisPresent(): Set = + jniLibs.keys.map { it.removePrefix("jni/").substringBefore('/') }.toSet() + + @Test + fun givenTheCommittedAar_whenLocated_thenItExists() { + assertTrue("Missing prebuilt AAR at ${aar.absolutePath}", aar.isFile) + } + + @Test + fun givenTheCommittedAar_whenInspected_thenItShipsTheExpectedAbi() { + assertTrue( + "AAR carries $expectedAbi? Found ${abisPresent()}", + abisPresent().contains(expectedAbi), + ) + } + + @Test + fun givenTheCommittedAar_whenInspected_thenTheExpectedAbiCarriesEveryNativeLibrary() { + // A partial native build drops one .so and fails at load time, not at build time. + val actual = jniLibs.keys + .filter { it.startsWith("jni/$expectedAbi/") } + .map { it.substringAfterLast('/') } + .toSet() + // Subset, not equality: an upstream llama.cpp bump may legitimately add a library. + assertEquals( + "Prebuilt AAR is missing native libraries (found $actual)", + emptySet(), + REQUIRED_LIBS - actual, + ) + } + + @Test + fun givenTheCommittedAar_whenInspected_thenTheJniWrapperIsNotEmpty() { + val size = jniLibs["jni/$expectedAbi/libllama-android.so"] ?: -1 + assertTrue("libllama-android.so is missing or empty (size=$size)", size > 0) + } + + private companion object { + const val DEFAULT_ABI = "arm64-v8a" + + const val AAR_RELATIVE_PATH = "libs/v8/llama-v8-release.aar" + + /** The libraries llama.cpp has to produce for the wrapper to load at all. */ + val REQUIRED_LIBS = setOf( + "libggml-base.so", + "libggml-cpu.so", + "libggml.so", + "libllama-android.so", + "libllama.so", + "libomp.so", + ) + } +}