Skip to content
Merged
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
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
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 @@ -7,7 +7,8 @@ import com.itsaky.androidide.plugins.aicore.models.ToolResult
* [AgentLoop] builds one per run and asks it about every batch before running it, so the loop
* orchestrates turns and this decides what counts as progress.
*
* @param maxConsecutiveRepeats identical unsuccessful batches tolerated back to back.
* @param maxConsecutiveRepeats identical unsuccessful batches, or successful log re-reads, tolerated
* back to back.
* @param maxTurnsWithoutProgress turns tolerated introducing no batch the run has not already run.
* @param pathsOf the project paths one call names, whether it reads them or rewrites them.
* @param changesPaths whether a call rewrites what it names, so a run is not judged on novelty
Expand Down Expand Up @@ -49,6 +50,7 @@ internal class ToolCallProgressGuard(
private var currentBatchWrites = emptySet<String>()
private var currentBatchChanges = false
private var currentBatchIsNew = false
private var currentBatchRereadsLive = false

// Null until a batch has run: "no tools yet" and "the tools failed" end a run differently.
private var previousBatchSucceeded: Boolean? = null
Expand All @@ -69,6 +71,7 @@ internal class ToolCallProgressGuard(
currentBatchPaths = pathsNamedBy(calls)
currentBatchWrites = pathsNamedBy(calls.filter(changesPaths))
currentBatchChanges = calls.any(changesPaths)
currentBatchRereadsLive = calls.all { it.name in LIVE_READS }
val verdict = verdictFor(signature)
if (verdict == Verdict.PROCEED) {
previousSignature = signature
Expand Down Expand Up @@ -117,7 +120,7 @@ internal class ToolCallProgressGuard(
consecutiveRepeats = 0
return Verdict.PROCEED
}
if (previousBatchSucceeded == true) return Verdict.ASSUME_COMPLETE
if (previousBatchSucceeded == true && !currentBatchRereadsLive) return Verdict.ASSUME_COMPLETE
Comment thread
jatezzz marked this conversation as resolved.
consecutiveRepeats++
return if (consecutiveRepeats >= maxConsecutiveRepeats) Verdict.REPEATED else Verdict.PROCEED
}
Expand All @@ -132,4 +135,9 @@ internal class ToolCallProgressGuard(
calls.joinToString("|") { call ->
call.name + "(" + call.args.toSortedMap().entries.joinToString(",") { "${it.key}=${it.value}" } + ")"
}

private companion object {
/** Reads whose answer changes between calls, so re-issuing one does not mean the work is done. */
val LIVE_READS = setOf("read_app_logs", "read_ide_logs")
}
}
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,15 +23,28 @@ object BuiltInToolHandlers {
SearchProjectHandler(context),
OpenFileHandler(context),
ReadBuildOutputHandler(context),
) + (if (hostHasLogApi()) LogToolHandlers.create(context) else emptyList()) + listOf(
// Write tools
CreateFileHandler(context),
UpdateFileHandler(context),
EditFileHandler(context),
AddDependencyHandler(context),
// Build tools
RunAppHandler(context),
RunAppHandler(context, hasLogTools = hostHasLogApi()),
GradleSyncHandler(context),
// Template tool
GenerateFromTemplateHandler(context),
)

// 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
}

/** The log tools, kept apart so [LogSource] is only touched on a host that has it. */
private object LogToolHandlers {
fun create(context: PluginContext): List<ToolHandler> = listOf(
ReadLogsHandler(context, LogSource.APP),
ReadLogsHandler(context, LogSource.IDE),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.itsaky.androidide.plugins.aicore.tool.handlers

import com.itsaky.androidide.plugins.aicore.tool.AgentLoop
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: AgentLoop's cap, less room for the message. */
const val MAX_OUTPUT_CHARS = AgentLoop.DEFAULT_TOOL_OUTPUT_CHAR_LIMIT - 100

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

/**
* A read that fits is returned whole. Logs span runs, so otherwise the window starts at the newest
* crash if it and all after it fit, else at whichever is newer, that crash or the start of the
* newest run of error lines. With neither, or a log of nothing but errors, it keeps the newest lines.
*/
fun windowFor(entries: List<LogEntry>, hostTruncated: Boolean): OutputWindow {
if (!hostTruncated && fits(entries, MAX_OUTPUT_CHARS)) return OutputWindow(joined(entries), false)
val crash = entries.indexOfLast { isCrashLine(it) }.takeIf { it >= 0 }
val markedRoom = MAX_OUTPUT_CHARS - TRUNCATION_MARKER.length - 1
val anchor = crash?.takeIf { fits(entries.subList(it, entries.size), markedRoom) }
?: listOfNotNull(crash, newestErrorRunStart(entries)).maxOrNull()
val text = if (anchor != null) {
fromAnchor(entries.subList(anchor, entries.size), droppedBefore = hostTruncated || anchor > 0)
Comment thread
jatezzz marked this conversation as resolved.
} else {
tail(entries)
}
return OutputWindow(text = text, anchoredOnError = anchor != null)
}

/** [entries] whole if they fit, else their head and their newest lines, half the room each. */
private fun fromAnchor(entries: List<LogEntry>, droppedBefore: Boolean): String {
val prefix = if (droppedBefore) "$TRUNCATION_MARKER\n" else ""
val room = MAX_OUTPUT_CHARS - prefix.length
if (fits(entries, room)) return prefix + joined(entries)

val separator = "\n$TRUNCATION_MARKER\n"
val half = (room - separator.length) / 2
return prefix + headBody(entries, half) + separator + tailBody(entries, half)
}

/** The newest lines of [entries] that fit, marking what was dropped before them. */
private fun tail(entries: List<LogEntry>): String {
val prefix = "$TRUNCATION_MARKER\n"
return prefix + tailBody(entries, MAX_OUTPUT_CHARS - prefix.length)
}

/** The oldest whole lines of [entries] that fit in [room], else the start of the first. */
private fun headBody(entries: List<LogEntry>, room: Int): String {
val kept = fitCount(entries, room)
return if (kept > 0) joined(entries.subList(0, kept)) else keepStart(entries.first().text, room)
}

/** The newest whole lines of [entries] that fit in [room], else the end of the last. */
private fun tailBody(entries: List<LogEntry>, room: Int): String {
val kept = fitCount(entries.asReversed(), room)
return if (kept > 0) {
joined(entries.subList(entries.size - kept, entries.size))
} else {
keepEnd(entries.last().text, room)
}
}

/** 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 fits(entries: List<LogEntry>, room: Int): Boolean = fitCount(entries, room) == entries.size

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)
}

/**
* The first line of the newest contiguous run of ERROR lines, so its trace keeps its head; null
* when every line is an ERROR, where a head would keep the oldest errors.
*/
private fun newestErrorRunStart(entries: List<LogEntry>): Int? {
var start = entries.indexOfLast { it.level == LogLevel.ERROR }.takeIf { it >= 0 } ?: return null
while (start > 0 && entries[start - 1].level == LogLevel.ERROR) start--
return start.takeUnless { it == 0 && entries.last().level == LogLevel.ERROR }
}

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,10 @@
package com.itsaky.androidide.plugins.aicore.tool.handlers

/** Marks where a window dropped text, shared so the build and log windows read the same. */
internal const val TRUNCATION_MARKER = "...[truncated]..."

/** The slice of a build or app/IDE log handed to the model, and whether it starts at an error. */
internal data class OutputWindow(
val text: String,
val anchoredOnError: Boolean,
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,6 @@ import com.itsaky.androidide.plugins.aicore.tool.ToolHandler
import com.itsaky.androidide.plugins.services.IdeBuildService
import kotlinx.coroutines.CancellationException

/** The slice of a build log handed to the model, and whether it starts at the first error. */
internal data class OutputWindow(
val text: String,
val anchoredOnError: Boolean,
)

/**
* Handler for reading the current build output.
*/
Expand Down Expand Up @@ -80,8 +74,6 @@ class ReadBuildOutputHandler(
/** Maximum characters of build log handed to the model. */
internal const val MAX_OUTPUT_CHARS = 8000

private const val TRUNCATION_MARKER = "...[truncated]...\n"

// The host strips line timing prefixes; tolerated here so the window is right either way.
private val LINE_PREFIX = Regex("""^(?:\[\d{2}:\d{2}:\d{2}\.\d{3}] )?(?:Δ\d+ms\s+)?""")

Expand Down Expand Up @@ -112,7 +104,7 @@ class ReadBuildOutputHandler(
val text = if (overflows) body.takeLast(MAX_OUTPUT_CHARS) else body
val dropped = overflows || (errorOffset ?: 0) > 0
return OutputWindow(
text = if (dropped) TRUNCATION_MARKER + text else text,
text = if (dropped) "$TRUNCATION_MARKER\n$text" else text,
anchoredOnError = errorOffset != null && !overflows,
)
}
Expand Down
Loading
Loading