Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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 = 5
versionName = "3.1.0"
versionCode = 6
versionName = "3.2.0"
}

buildFeatures {
Expand Down
2 changes: 1 addition & 1 deletion plugins/AI-Core/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@

<meta-data
android:name="plugin.min_ide_version"
android:value="26.39" />
android:value="26.40" />
Comment thread
jatezzz marked this conversation as resolved.
Outdated

<meta-data
android:name="plugin.max_ide_version"
Expand Down
3 changes: 3 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,9 @@ <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>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>
<li>Add dependencies and generate code from templates, both after asking.</li>
</ul>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class Executor(
"read_file",
"list_files",
"search_project",
"read_app_logs",
"read_ide_logs",
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.itsaky.androidide.plugins.aicore.tool.handlers

import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.aicore.tool.ToolHandler
import com.itsaky.androidide.plugins.services.LogSource

/**
* The agent's own tool catalogue, in one place rather than inline in the chat: what the agent can
Expand All @@ -22,6 +23,8 @@ object BuiltInToolHandlers {
SearchProjectHandler(context),
OpenFileHandler(context),
ReadBuildOutputHandler(context),
ReadLogsHandler(context, LogSource.APP),
ReadLogsHandler(context, LogSource.IDE),
// Write tools
CreateFileHandler(context),
UpdateFileHandler(context),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.itsaky.androidide.plugins.aicore.tool.handlers

import com.itsaky.androidide.plugins.services.LogEntry
import com.itsaky.androidide.plugins.services.LogLevel

/**
* Selects the slice of a log the model needs, in whole lines and within [MAX_OUTPUT_CHARS]
* including the truncation markers. Only the kept lines are ever joined into a string.
*/
internal object LogWindowCalculator {
/** Maximum characters of log handed to the model, the same budget as build output. */
const val MAX_OUTPUT_CHARS = 8000
Comment thread
jatezzz marked this conversation as resolved.
Outdated

private const val TRUNCATION_MARKER = "...[truncated]..."
Comment thread
jatezzz marked this conversation as resolved.
Outdated

/** Markers of an app crash; the anchor prefers these over any earlier plain error. */
private val CRASH_MARKERS = listOf("FATAL EXCEPTION", "Fatal signal")

/**
* A crash's exception and top frames sit where it starts, not in the tail of later chatter,
* so the window starts at the first crash line, else the first error line, and keeps the
* head of what follows. With neither, it keeps the newest lines.
*/
fun windowFor(entries: List<LogEntry>, hostTruncated: Boolean): OutputWindow {
val anchor = entries.indexOfFirst { isCrashLine(it) }.takeIf { it >= 0 }
Comment thread
jatezzz marked this conversation as resolved.
Outdated
?: entries.indexOfFirst { it.level == LogLevel.ERROR }.takeIf { it >= 0 }
val text = if (anchor != null) {
head(entries.subList(anchor, entries.size), droppedBefore = hostTruncated || anchor > 0)
} else {
tail(entries, droppedBefore = hostTruncated)
}
return OutputWindow(text = text, anchoredOnError = anchor != null)
}

/** The oldest lines of [entries] that fit, marking what was dropped on either side. */
private fun head(entries: List<LogEntry>, droppedBefore: Boolean): String {
val prefix = if (droppedBefore) "$TRUNCATION_MARKER\n" else ""
val room = MAX_OUTPUT_CHARS - prefix.length
if (fitCount(entries, room) == entries.size) return prefix + joined(entries)

val suffix = "\n$TRUNCATION_MARKER"
val kept = fitCount(entries, room - suffix.length)
val body = if (kept > 0) {
joined(entries.subList(0, kept))
} else {
keepStart(entries.first().text, room - suffix.length)
}
return prefix + body + suffix
}

/** The newest lines of [entries] that fit, marking what was dropped before them. */
private fun tail(entries: List<LogEntry>, droppedBefore: Boolean): String {
val prefix = "$TRUNCATION_MARKER\n"
val newestFirst = entries.asReversed()
if (!droppedBefore && fitCount(newestFirst, MAX_OUTPUT_CHARS) == entries.size) {
return joined(entries)
}

val room = MAX_OUTPUT_CHARS - prefix.length
val kept = fitCount(newestFirst, room)
val body = if (kept > 0) {
joined(entries.subList(entries.size - kept, entries.size))
} else {
keepEnd(entries.last().text, room)
}
return prefix + body
}

/** How many of [entries], taken in order, fit in [room] characters once joined by newlines. */
private fun fitCount(entries: List<LogEntry>, room: Int): Int {
var used = -1 // the first line needs no separator
var count = 0
for (entry in entries) {
used += entry.text.length + 1
if (used > room) break
count++
}
return count
}

private fun joined(entries: List<LogEntry>): String = entries.joinToString("\n") { it.text }

/** The start of one oversized line, never splitting a surrogate pair. */
private fun keepStart(text: String, room: Int): String {
val end = if (text[room - 1].isHighSurrogate()) room - 1 else room
return text.substring(0, end)
}

/** The end of one oversized line, never splitting a surrogate pair. */
private fun keepEnd(text: String, room: Int): String {
val start = text.length - room
return text.substring(if (text[start].isLowSurrogate()) start + 1 else start)
}

private fun isCrashLine(entry: LogEntry): Boolean =
CRASH_MARKERS.any { entry.text.contains(it, ignoreCase = true) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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.IdeLogService
Comment thread
jatezzz marked this conversation as resolved.
import com.itsaky.androidide.plugins.services.LogLevel
import com.itsaky.androidide.plugins.services.LogQuery
import com.itsaky.androidide.plugins.services.LogSource
import kotlinx.coroutines.CancellationException

/**
* Handler for reading one of the IDE's log tabs: App Logs (`read_app_logs`, the user's running
* app) or IDE Logs (`read_ide_logs`, CoGo and its plugins). Read-only, so it never asks.
*/
class ReadLogsHandler(
private val pluginContext: PluginContext,
private val source: LogSource,
) : ToolHandler {
private val tab = tabFor(source)

override val toolName = tab.toolName
override val description = tab.description
override val requiresApproval = false
override val parametersSchema = ToolSchema.objectOf(
"min_level" to ToolSchema.string(
"Lowest level to include: verbose, debug, info, warning or error. Defaults to all levels."
),
"filter" to ToolSchema.string(
"Case-insensitive text a line must contain, matched against its tag and message."
),
)

private val label = tab.label

override suspend fun execute(args: Map<String, Any?>): ToolResult {
val minLevel = args["min_level"]?.toString()?.trim().orEmpty()
val levels = if (minLevel.isEmpty()) {
emptySet()
} else {
levelsFrom(minLevel) ?: return ToolResult.failure(
"Unknown min_level '$minLevel'",
"Use one of: verbose, debug, info, warning, error."
)
}
val filter = args["filter"]?.toString()?.trim().orEmpty()

return try {
val logService = pluginContext.services.get(IdeLogService::class.java)
if (logService == null) {
// A clear answer rather than a failure: the agent can say so and carry on.
AgentTrace.refusal("LOGS", "$toolName rejected", "IdeLogService not available")
return ToolResult.success(
message = "$label not available",
data = "(This version of Code on the Go does not let plugins read $label.)"
)
}

val result = logService.readLogs(
source,
LogQuery(levels = levels, text = filter, maxLines = LogQuery.MAX_LINES),
)
if (result.entries.isEmpty()) {
AgentTrace.detail("LOGS", "$toolName lines=0 levels=$levels filter=${filter.isNotEmpty()}")
ToolResult.success(message = "No $label", data = emptyMessage(filter, levels))
} else {
val window = LogWindowCalculator.windowFor(result.entries, result.truncated)
AgentTrace.detail(
"LOGS",
"$toolName lines=${result.entries.size} chars=${window.text.length} " +
"anchoredOnError=${window.anchoredOnError} hostTruncated=${result.truncated}"
)
ToolResult.success(
message = if (window.anchoredOnError) {
"$label from the first error (${window.text.length} characters)"
} else {
"$label (last ${window.text.length} characters)"
},
data = window.text
)
}
} catch (ce: CancellationException) {
// An Exception on the JVM, so the catch below would report Stop as a read failure.
throw ce
} catch (e: Exception) {
AgentTrace.refusal("LOGS", "$toolName failed", e.toString())
pluginContext.logger.error("$toolName failed", e)
ToolResult.failure(
"Error reading $label",
"${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}"
)
}
}

private fun emptyMessage(filter: String, levels: Set<LogLevel>): String =
if (filter.isEmpty() && levels.isEmpty()) {
tab.emptyLog
} else {
"(No $label lines match that level or filter. Try again without them.)"
}

/** How the tool for one log tab is named and described, to the model and in messages. */
private class LogTab(
val toolName: String,
val description: String,
val label: String,
val emptyLog: String,
)

companion object {
private val ORDERED_LEVELS = listOf(
LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARNING, LogLevel.ERROR,
)

/**
* The levels at or above [name], which the host takes as an exact set.
* @param name a level name, case-insensitive; `warn` and `e`-style initials are accepted.
* @return the levels, or null when [name] is not a level.
*/
internal fun levelsFrom(name: String): Set<LogLevel>? {
val level = when (name.lowercase()) {
"verbose", "v" -> LogLevel.VERBOSE
"debug", "d" -> LogLevel.DEBUG
"info", "i" -> LogLevel.INFO
"warning", "warn", "w" -> LogLevel.WARNING
"error", "e" -> LogLevel.ERROR
else -> return null
}
return ORDERED_LEVELS.subList(ORDERED_LEVELS.indexOf(level), ORDERED_LEVELS.size).toSet()
}

private fun tabFor(source: LogSource): LogTab = when (source) {
LogSource.APP -> LogTab(
toolName = "read_app_logs",
Comment thread
jatezzz marked this conversation as resolved.
description = "Read the App Logs of the user's running app, from the first crash or error",
label = "App Logs",
emptyLog = "(App Logs are empty. Run the app with run_app first, then read them again.)",
Comment thread
jatezzz marked this conversation as resolved.
Outdated
)
LogSource.IDE -> LogTab(
toolName = "read_ide_logs",
description = "Read the IDE Logs of Code on the Go and its plugins, from the first error",
label = "IDE Logs",
emptyLog = "(IDE Logs are empty.)",
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ class RunAppHandler(
if (success) {
ToolResult.success(
message = "Build succeeded",
data = message
data = "$message\n\nIf the app crashes or misbehaves, call read_app_logs."
Comment thread
jatezzz marked this conversation as resolved.
Outdated
)
} else {
ToolResult.failure(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ class BuiltInHandlerApprovalTest {
"search_project",
"open_file",
"read_build_output",
"read_app_logs",
"read_ide_logs",
)
}

Expand Down
Loading
Loading