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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions plugins/AI-Core/ai-core.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ <h2>Executive overview</h2>
<h2>Core functionality</h2>
<ul>
<li><b>Agent chat</b> — a conversational editor tab that can read and edit
project files, add dependencies, run a Gradle sync and launch the app, with
every file-changing action gated behind an approval dialog.</li>
project files, add dependencies, run a Gradle sync, list and run any Gradle
task (tests, lint, clean, your own) and launch the app, with every file-changing action
gated behind an approval dialog.</li>
<li><b>Agent settings</b> — one screen to pick the backend and configure it;
each backend contributes its own portion of that screen.</li>
<li><b>Web search</b> — the agent always has <code>web_search</code> (the
Expand Down Expand Up @@ -138,8 +139,8 @@ <h2>Technical architecture</h2>
</table>
<p>AI Core declares <b>filesystem.read</b>, <b>filesystem.write</b>,
<b>system.commands</b> and <b>project.structure</b> — the agent reads and edits
files in the open project, triggers Gradle sync and run through the IDE's own
build service, and inspects the project's module structure. It declares
files in the open project, triggers Gradle sync, Gradle tasks and run through
the IDE's own build service, and inspects the project's module structure. It declares
<b>no network access and loads no native code</b>: those belong to the backend
plugin that serves a given request, so a device using only the local backend
never grants a network-capable AI plugin. The same holds for tools another
Expand All @@ -157,7 +158,7 @@ <h2>Usage</h2>
enter a Gemini API key. The same screen is reachable from the Agent tab.</li>
<li>Open a project and switch to the <b>Agent</b> tab to start chatting. Any
action that changes a file asks for your approval first, as does starting a
Gradle sync or generating from a template. Only reads run unprompted.</li>
Gradle sync or task, or generating from a template. Only reads run unprompted.</li>
<li><i>Optional:</i> install a tool provider such as <b>AI Agent MCP</b> to
give the agent tools beyond its own. Its tools appear in the agent's tool
list once configured, and each asks for approval naming the plugin it came
Expand Down
4 changes: 2 additions & 2 deletions plugins/AI-Core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ android {
applicationId = "com.itsaky.androidide.plugins.aicore"
minSdk = 33
targetSdk = 36
versionCode = 6
versionName = "3.2.0"
versionCode = 7
versionName = "3.3.0"
}

buildFeatures {
Expand Down
8 changes: 8 additions & 0 deletions plugins/AI-Core/src/main/assets/docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ <h2>What the agent can do</h2>
so <b>Ctrl+Z undoes it</b> and your unsaved work is preserved.</li>
<li>Trigger a Gradle sync (asks for approval — a sync starts a real build)
and read build output.</li>
<li>Run any Gradle task, such as <code>test</code>, <code>lint</code> or
<code>clean</code>, with Gradle arguments such as
<code>--tests com.example.FooTest</code>. It asks first, runs on the IDE's
own Gradle daemon, and reports whether the task passed along with the test
failures or compiler errors. Pressing Stop cancels the task.</li>
<li>List the project's Gradle tasks, your own custom tasks included, with
each task's group and description as of the last sync, so it runs a task
that exists. A task you just added appears after the next Gradle sync.</li>
<li>Read <b>App Logs</b> and <b>IDE Logs</b>, so it can find the exception
behind a crash without you copying log lines into the chat. It never
asks first: reading a log changes nothing.</li>
Expand Down
26 changes: 26 additions & 0 deletions plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,32 @@ built_in_tools:
prompt, so success means the install started, not that the app is on screen
gradle_sync:
description: Sync the Gradle project (reload dependencies and rebuild cache)
run_gradle_task:
# Without "any task" small models still answer that they cannot run tests, and without the
# second sentence Gemini answers a repeated "run the tests" from the earlier result.
description: >-
Run any Gradle task in the project — tests, lint, clean, assemble — and get back whether it
passed and the build output, test failures and compiler errors included. A result from an
earlier message is out of date: whenever the user asks to run a task, call this tool again
instead of repeating that result. Unsure of a task's name? Call list_gradle_tasks first
arguments:
tasks: >-
The Gradle tasks to run, separated by spaces, e.g. "test" or ":app:testDebugUnitTest".
arguments: >-
Gradle command-line arguments, separated by spaces, e.g. "--tests com.example.FooTest"
or "--info". A task option such as --tests applies to the last task. Gradle skips a task
whose inputs have not changed; add "--rerun" to run the last task anyway. Omit when there
are none.
list_gradle_tasks:
# Without "custom tasks" models answer from Gradle and Android defaults and miss the project's own.
description: >-
List the Gradle tasks this project has, the project's own custom tasks included, with each
task's path, group and what it does. Call it before run_gradle_task when you are unsure a
task exists or which task does what the user asked
arguments:
filter: >-
Text to search task paths, groups and descriptions for, e.g. ":app", "test" or "lint".
Omit to list the tasks that have a group or a description.
generate_from_template:
description: Generate files from Pebble templates with variable substitution
arguments:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.itsaky.androidide.plugins.aicore.tool.handlers

// Shared by every handler that waits on a build, so run_app and run_gradle_task give up together.

/** How long to wait for a build to finish before reporting it still running. */
internal const val BUILD_TIMEOUT_MS = 10 * 60 * 1000L

/**
* How often to log that the wait is still alive. A build can hold the agent for ten minutes, and
* without a heartbeat that stretch of logcat is indistinguishable from a hung agent.
*/
internal const val BUILD_PROGRESS_LOG_INTERVAL_MS = 30 * 1000L
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ object BuiltInToolHandlers {
// Build tools
RunAppHandler(context, hasLogTools = hostHasLogApi()),
GradleSyncHandler(context),
) + (if (hostHasGradleTaskApi()) listOf(RunGradleTaskHandler(context)) else emptyList()) +
(if (hostHasTaskListApi()) listOf(ListGradleTasksHandler(context)) else emptyList()) + listOf(
// Template tool
GenerateFromTemplateHandler(context),
// Web tools
Expand All @@ -47,6 +49,14 @@ object BuiltInToolHandlers {
// A string, not a class literal: hosts before ADFA-6267 lack the class, and the literal would throw.
private fun hostHasLogApi(): Boolean =
runCatching { Class.forName("com.itsaky.androidide.plugins.services.IdeLogService") }.isSuccess

// Same reason: a 26.41 host from before ADFA-6373 lacks executeTasks(tasks, arguments).
private fun hostHasGradleTaskApi(): Boolean =
runCatching { Class.forName("com.itsaky.androidide.plugins.services.GradleTaskResult") }.isSuccess

// Same reason: a host from before IdeBuildService.getTasks lacks the class, and the call would throw.
private fun hostHasTaskListApi(): Boolean =
runCatching { Class.forName("com.itsaky.androidide.plugins.services.GradleTaskInfo") }.isSuccess
}

/** The log tools, kept apart so [LogSource] is only touched on a host that has it. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.itsaky.androidide.plugins.aicore.tool.handlers

/** Tasks and Gradle arguments as the host takes them. */
internal data class GradleInvocation(val tasks: List<String>, val arguments: List<String>)

/**
* Reads the model's `tasks` and `arguments` into a [GradleInvocation]. Knows Gradle's command line,
* not the tool's argument names, so [RunGradleTaskHandler] keeps the call shape to itself.
*/
internal object GradleCommandLine {

// Gradle options whose value is the next token rather than `=value`.
private val VALUE_OPTIONS = setOf(
"-x", "--exclude-task", "--tests", "-p", "--project-dir", "-I", "--init-script",
"-g", "--gradle-user-home", "--console", "--warning-mode", "--max-workers", "--include-build",
)

/**
* Leading options, and everything from the first option after a task, move from [tasks] to the
* arguments: models write `tasks="test --tests Foo"` as often as not.
* @param tasks the tasks value as the model gave it: a string, a list, or null.
* @param arguments the arguments value, read the same way.
* @return the invocation; its task list is empty when the call names none.
*/
fun parse(tasks: Any?, arguments: Any?): GradleInvocation {
val taskTokens = tokensOf(tasks)
var start = 0
while (start < taskTokens.size && taskTokens[start].startsWith("-")) {
// `-x lint test` must not run lint, so a leading option's own value stays with it.
start += if (taskTokens[start] in VALUE_OPTIONS) 2 else 1
}
start = start.coerceAtMost(taskTokens.size)
val firstOption = (start until taskTokens.size)
.firstOrNull { taskTokens[it].startsWith("-") } ?: taskTokens.size
return GradleInvocation(
tasks = taskTokens.subList(start, firstOption),
arguments = taskTokens.subList(0, start) +
taskTokens.subList(firstOption, taskTokens.size) + tokensOf(arguments),
)
}

/**
* Splits a tool argument into command-line tokens: a string on whitespace with single or
* double quotes grouping, so `--tests "com.example.Foo*"` stays one value; a list per entry.
*/
fun tokensOf(value: Any?): List<String> = when (value) {
null -> emptyList()
is List<*> -> value.filterNotNull().flatMap { splitCommandLine(it.toString()) }
else -> splitCommandLine(value.toString())
}.filter(String::isNotEmpty)

private fun splitCommandLine(text: String): List<String> {
val tokens = mutableListOf<String>()
val current = StringBuilder()
var quote: Char? = null
var inToken = false
for (c in text) {
when {
quote != null && c == quote -> quote = null
quote != null -> current.append(c)
c == '"' || c == '\'' -> { quote = c; inToken = true }
c.isWhitespace() -> if (inToken) {
tokens += current.toString()
current.clear()
inToken = false
}
else -> { current.append(c); inToken = true }
}
}
if (inToken) tokens += current.toString()
return tokens
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.itsaky.androidide.plugins.aicore.tool.handlers

import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.aicore.logging.AgentTrace
import com.itsaky.androidide.plugins.aicore.models.ToolResult
import com.itsaky.androidide.plugins.aicore.tool.ToolHandler
import com.itsaky.androidide.plugins.aicore.tool.ToolSchema
import com.itsaky.androidide.plugins.services.GradleTaskInfo
import com.itsaky.androidide.plugins.services.IdeBuildService
import kotlinx.coroutines.CancellationException

/**
* Handler for listing the project's Gradle tasks with their group and description, from the IDE's
* last sync, so the agent picks a task that exists before it calls run_gradle_task.
*/
class ListGradleTasksHandler(
private val pluginContext: PluginContext,
) : ToolHandler {
override val toolName = "list_gradle_tasks"
override val requiresApproval = false

override val parametersSchema = ToolSchema.objectOf(
"filter" to ToolSchema.string(),
)

override val argAliases = mapOf(
"query" to "filter",
"search" to "filter",
"module" to "filter",
"group" to "filter",
)

override suspend fun execute(args: Map<String, Any?>): ToolResult {
val filter = (args["filter"] as? String)?.trim().orEmpty()
return try {
val buildService = pluginContext.services.get(IdeBuildService::class.java)
if (buildService == null) {
AgentTrace.refusal("BUILD", "$toolName rejected", "IdeBuildService not available")
return ToolResult.failure(
"Build service not available",
"The IDE build service is not available."
)
}
val tasks = buildService.getTasks()
AgentTrace.detail("BUILD", "$toolName filter='$filter' hostTasks=${tasks.size}")
resultFor(tasks, filter)
} catch (ce: CancellationException) {
// An Exception on the JVM, so the catch below would report Stop as a listing failure.
throw ce
} catch (e: Exception) {
AgentTrace.refusal("BUILD", "$toolName failed", e.toString())
pluginContext.logger.error("$toolName failed", e)
ToolResult.failure(
"Error listing Gradle tasks",
"${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}"
)
}
}

companion object {
/** Maximum characters of the whole result, message included: the prompt cuts a longer one. */
internal const val MAX_LIST_CHARS = LogWindowCalculator.MAX_OUTPUT_CHARS

private const val NOT_SYNCED =
"The IDE has no task list: the project is not open or has not synced. Call gradle_sync " +
"and then list the tasks again."

private const val NO_DESCRIPTIONS =
"\n\n[Descriptions left out to fit every task. Pass a filter to see them.]"

private const val CUT_SHORT =
"\n[List cut short. Pass a filter such as a module (\":app\") or a group " +
"(\"verification\") to see the rest.]"

/**
* The listing for [tasks]. Without [filter] it shows grouped tasks and described ones, so a
* user's ungrouped task shows; a filter searches every task by path, group and description.
*/
internal fun resultFor(tasks: List<GradleTaskInfo>, filter: String): ToolResult {
if (tasks.isEmpty()) return ToolResult.failure("No Gradle tasks", NOT_SYNCED)

val shown = if (filter.isEmpty()) {
// Plugin-internal tasks such as compileDebugKotlin carry neither; a user's task has one.
tasks.filter { it.group != null || it.description != null }
} else {
tasks.filter { it.matches(filter) }
}
if (shown.isEmpty()) {
return ToolResult.success(
message = "No Gradle task matches \"$filter\"",
data = "None of the ${tasks.size} tasks matches. Try a shorter filter, or omit " +
"it to see the main tasks. A task added since the last sync needs " +
"gradle_sync first."
)
}

val hidden = tasks.size - shown.size
val note = if (filter.isEmpty() && hidden > 0) {
"\n\n$hidden tasks with no group or description are not shown; pass a filter to " +
"search them too."
} else {
""
}
val message = if (filter.isEmpty()) {
"${shown.size} Gradle tasks"
} else {
"${shown.size} Gradle tasks matching \"$filter\""
}
// The prompt sends the message, a newline and the data, so the listing gets what is left.
val room = MAX_LIST_CHARS - message.length - 1 - note.length

// Descriptions go before tasks do: a cut drops the last groups, the user's own among them.
val full = render(shown, withDescriptions = true)
val listing = if (full.length <= room) {
full
} else {
render(shown, withDescriptions = false) + NO_DESCRIPTIONS
}
val text = if (listing.length > room) {
listing.take(room - CUT_SHORT.length).substringBeforeLast('\n') + CUT_SHORT
} else {
listing
}
return ToolResult.success(message = message, data = "$text$note")
}

private fun GradleTaskInfo.matches(filter: String): Boolean =
listOfNotNull(path, group, description).any { it.contains(filter, ignoreCase = true) }

/** One block per group, "path — description" per task; ungrouped tasks come last. */
private fun render(tasks: List<GradleTaskInfo>, withDescriptions: Boolean): String =
tasks.groupBy { it.group ?: "other" }
.entries
.sortedWith(compareBy({ it.key == "other" }, { it.key }))
.joinToString("\n\n") { (group, inGroup) ->
"$group:\n" + inGroup.joinToString("\n") { task ->
task.description?.takeIf { withDescriptions }?.let { "${task.path} — $it" } ?: task.path
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,6 @@ import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume

/** How long to wait for the build callback before reporting the build still running. */
internal const val BUILD_TIMEOUT_MS = 10 * 60 * 1000L

/**
* How often to log that the wait is still alive. A build can hold the agent for ten minutes, and
* without a heartbeat that stretch of logcat is indistinguishable from a hung agent.
*/
internal const val BUILD_PROGRESS_LOG_INTERVAL_MS = 30 * 1000L

/**
* Handler for running/building the app.
*/
Expand Down
Loading