Skip to content

Commit 7143e29

Browse files
authored
ADFA-6268 | Add read_app_logs and read_ide_logs agent tools (#113)
* feat(ai-core): add read_app_logs and read_ide_logs agent tools (ADFA-6268) Read-only tools over IdeLogService, capped at 8000 chars and anchored on the first crash or error. run_app points the agent at read_app_logs; min_ide_version is now 26.40. * fix(ai-core): address review on log tools (ADFA-6268) - Keep min_ide_version 26.39; register log tools only when the host has IdeLogService - Size the log window to AgentLoop's tool-output cap - Anchor on the newest crash / newest error run instead of the oldest - Don't treat a repeated log read as the run completing - Share OutputWindow and the truncation marker between build and log handlers * fix(ai-core): address second review on log tools Anchor on the newer of crash/error run, tail all-ERROR reads, exempt only all-log-read batches, gate the run_app hint. Refs: ADFA-6268 * fix(ai-core): return small log reads whole, anchor on crash when it fits
1 parent 5bac6ee commit 7143e29

15 files changed

Lines changed: 741 additions & 18 deletions

File tree

‎plugins/AI-Core/build.gradle.kts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ android {
1515
applicationId = "com.itsaky.androidide.plugins.aicore"
1616
minSdk = 33
1717
targetSdk = 36
18-
versionCode = 5
19-
versionName = "3.1.0"
18+
versionCode = 6
19+
versionName = "3.2.0"
2020
}
2121

2222
buildFeatures {

‎plugins/AI-Core/src/main/assets/docs/index.html‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ <h2>What the agent can do</h2>
103103
so <b>Ctrl+Z undoes it</b> and your unsaved work is preserved.</li>
104104
<li>Trigger a Gradle sync (asks for approval — a sync starts a real build)
105105
and read build output.</li>
106+
<li>Read <b>App Logs</b> and <b>IDE Logs</b>, so it can find the exception
107+
behind a crash without you copying log lines into the chat. It never
108+
asks first: reading a log changes nothing.</li>
106109
<li>Add dependencies and generate code from templates, both after asking.</li>
107110
</ul>
108111

‎plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/Executor.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ class Executor(
2727
"read_file",
2828
"list_files",
2929
"search_project",
30+
"read_app_logs",
31+
"read_ide_logs",
3032
)
3133

3234
/**

‎plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuard.kt‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import com.itsaky.androidide.plugins.aicore.models.ToolResult
77
* [AgentLoop] builds one per run and asks it about every batch before running it, so the loop
88
* orchestrates turns and this decides what counts as progress.
99
*
10-
* @param maxConsecutiveRepeats identical unsuccessful batches tolerated back to back.
10+
* @param maxConsecutiveRepeats identical unsuccessful batches, or successful log re-reads, tolerated
11+
* back to back.
1112
* @param maxTurnsWithoutProgress turns tolerated introducing no batch the run has not already run.
1213
* @param pathsOf the project paths one call names, whether it reads them or rewrites them.
1314
* @param changesPaths whether a call rewrites what it names, so a run is not judged on novelty
@@ -49,6 +50,7 @@ internal class ToolCallProgressGuard(
4950
private var currentBatchWrites = emptySet<String>()
5051
private var currentBatchChanges = false
5152
private var currentBatchIsNew = false
53+
private var currentBatchRereadsLive = false
5254

5355
// Null until a batch has run: "no tools yet" and "the tools failed" end a run differently.
5456
private var previousBatchSucceeded: Boolean? = null
@@ -69,6 +71,7 @@ internal class ToolCallProgressGuard(
6971
currentBatchPaths = pathsNamedBy(calls)
7072
currentBatchWrites = pathsNamedBy(calls.filter(changesPaths))
7173
currentBatchChanges = calls.any(changesPaths)
74+
currentBatchRereadsLive = calls.all { it.name in LIVE_READS }
7275
val verdict = verdictFor(signature)
7376
if (verdict == Verdict.PROCEED) {
7477
previousSignature = signature
@@ -117,7 +120,7 @@ internal class ToolCallProgressGuard(
117120
consecutiveRepeats = 0
118121
return Verdict.PROCEED
119122
}
120-
if (previousBatchSucceeded == true) return Verdict.ASSUME_COMPLETE
123+
if (previousBatchSucceeded == true && !currentBatchRereadsLive) return Verdict.ASSUME_COMPLETE
121124
consecutiveRepeats++
122125
return if (consecutiveRepeats >= maxConsecutiveRepeats) Verdict.REPEATED else Verdict.PROCEED
123126
}
@@ -132,4 +135,9 @@ internal class ToolCallProgressGuard(
132135
calls.joinToString("|") { call ->
133136
call.name + "(" + call.args.toSortedMap().entries.joinToString(",") { "${it.key}=${it.value}" } + ")"
134137
}
138+
139+
private companion object {
140+
/** Reads whose answer changes between calls, so re-issuing one does not mean the work is done. */
141+
val LIVE_READS = setOf("read_app_logs", "read_ide_logs")
142+
}
135143
}

‎plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.itsaky.androidide.plugins.aicore.tool.handlers
22

33
import com.itsaky.androidide.plugins.PluginContext
44
import com.itsaky.androidide.plugins.aicore.tool.ToolHandler
5+
import com.itsaky.androidide.plugins.services.LogSource
56

67
/**
78
* The agent's own tool catalogue, in one place rather than inline in the chat: what the agent can
@@ -22,15 +23,28 @@ object BuiltInToolHandlers {
2223
SearchProjectHandler(context),
2324
OpenFileHandler(context),
2425
ReadBuildOutputHandler(context),
26+
) + (if (hostHasLogApi()) LogToolHandlers.create(context) else emptyList()) + listOf(
2527
// Write tools
2628
CreateFileHandler(context),
2729
UpdateFileHandler(context),
2830
EditFileHandler(context),
2931
AddDependencyHandler(context),
3032
// Build tools
31-
RunAppHandler(context),
33+
RunAppHandler(context, hasLogTools = hostHasLogApi()),
3234
GradleSyncHandler(context),
3335
// Template tool
3436
GenerateFromTemplateHandler(context),
3537
)
38+
39+
// A string, not a class literal: hosts before ADFA-6267 lack the class, and the literal would throw.
40+
private fun hostHasLogApi(): Boolean =
41+
runCatching { Class.forName("com.itsaky.androidide.plugins.services.IdeLogService") }.isSuccess
42+
}
43+
44+
/** The log tools, kept apart so [LogSource] is only touched on a host that has it. */
45+
private object LogToolHandlers {
46+
fun create(context: PluginContext): List<ToolHandler> = listOf(
47+
ReadLogsHandler(context, LogSource.APP),
48+
ReadLogsHandler(context, LogSource.IDE),
49+
)
3650
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package com.itsaky.androidide.plugins.aicore.tool.handlers
2+
3+
import com.itsaky.androidide.plugins.aicore.tool.AgentLoop
4+
import com.itsaky.androidide.plugins.services.LogEntry
5+
import com.itsaky.androidide.plugins.services.LogLevel
6+
7+
/**
8+
* Selects the slice of a log the model needs, in whole lines and within [MAX_OUTPUT_CHARS]
9+
* including the truncation markers. Only the kept lines are ever joined into a string.
10+
*/
11+
internal object LogWindowCalculator {
12+
/** Maximum characters of log handed to the model: AgentLoop's cap, less room for the message. */
13+
const val MAX_OUTPUT_CHARS = AgentLoop.DEFAULT_TOOL_OUTPUT_CHAR_LIMIT - 100
14+
15+
/** Markers of an app crash; the anchor prefers these over a later error when the crash fits. */
16+
private val CRASH_MARKERS = listOf("FATAL EXCEPTION", "Fatal signal")
17+
18+
/**
19+
* A read that fits is returned whole. Logs span runs, so otherwise the window starts at the newest
20+
* crash if it and all after it fit, else at whichever is newer, that crash or the start of the
21+
* newest run of error lines. With neither, or a log of nothing but errors, it keeps the newest lines.
22+
*/
23+
fun windowFor(entries: List<LogEntry>, hostTruncated: Boolean): OutputWindow {
24+
if (!hostTruncated && fits(entries, MAX_OUTPUT_CHARS)) return OutputWindow(joined(entries), false)
25+
val crash = entries.indexOfLast { isCrashLine(it) }.takeIf { it >= 0 }
26+
val markedRoom = MAX_OUTPUT_CHARS - TRUNCATION_MARKER.length - 1
27+
val anchor = crash?.takeIf { fits(entries.subList(it, entries.size), markedRoom) }
28+
?: listOfNotNull(crash, newestErrorRunStart(entries)).maxOrNull()
29+
val text = if (anchor != null) {
30+
fromAnchor(entries.subList(anchor, entries.size), droppedBefore = hostTruncated || anchor > 0)
31+
} else {
32+
tail(entries)
33+
}
34+
return OutputWindow(text = text, anchoredOnError = anchor != null)
35+
}
36+
37+
/** [entries] whole if they fit, else their head and their newest lines, half the room each. */
38+
private fun fromAnchor(entries: List<LogEntry>, droppedBefore: Boolean): String {
39+
val prefix = if (droppedBefore) "$TRUNCATION_MARKER\n" else ""
40+
val room = MAX_OUTPUT_CHARS - prefix.length
41+
if (fits(entries, room)) return prefix + joined(entries)
42+
43+
val separator = "\n$TRUNCATION_MARKER\n"
44+
val half = (room - separator.length) / 2
45+
return prefix + headBody(entries, half) + separator + tailBody(entries, half)
46+
}
47+
48+
/** The newest lines of [entries] that fit, marking what was dropped before them. */
49+
private fun tail(entries: List<LogEntry>): String {
50+
val prefix = "$TRUNCATION_MARKER\n"
51+
return prefix + tailBody(entries, MAX_OUTPUT_CHARS - prefix.length)
52+
}
53+
54+
/** The oldest whole lines of [entries] that fit in [room], else the start of the first. */
55+
private fun headBody(entries: List<LogEntry>, room: Int): String {
56+
val kept = fitCount(entries, room)
57+
return if (kept > 0) joined(entries.subList(0, kept)) else keepStart(entries.first().text, room)
58+
}
59+
60+
/** The newest whole lines of [entries] that fit in [room], else the end of the last. */
61+
private fun tailBody(entries: List<LogEntry>, room: Int): String {
62+
val kept = fitCount(entries.asReversed(), room)
63+
return if (kept > 0) {
64+
joined(entries.subList(entries.size - kept, entries.size))
65+
} else {
66+
keepEnd(entries.last().text, room)
67+
}
68+
}
69+
70+
/** How many of [entries], taken in order, fit in [room] characters once joined by newlines. */
71+
private fun fitCount(entries: List<LogEntry>, room: Int): Int {
72+
var used = -1 // the first line needs no separator
73+
var count = 0
74+
for (entry in entries) {
75+
used += entry.text.length + 1
76+
if (used > room) break
77+
count++
78+
}
79+
return count
80+
}
81+
82+
private fun fits(entries: List<LogEntry>, room: Int): Boolean = fitCount(entries, room) == entries.size
83+
84+
private fun joined(entries: List<LogEntry>): String = entries.joinToString("\n") { it.text }
85+
86+
/** The start of one oversized line, never splitting a surrogate pair. */
87+
private fun keepStart(text: String, room: Int): String {
88+
val end = if (text[room - 1].isHighSurrogate()) room - 1 else room
89+
return text.substring(0, end)
90+
}
91+
92+
/** The end of one oversized line, never splitting a surrogate pair. */
93+
private fun keepEnd(text: String, room: Int): String {
94+
val start = text.length - room
95+
return text.substring(if (text[start].isLowSurrogate()) start + 1 else start)
96+
}
97+
98+
/**
99+
* The first line of the newest contiguous run of ERROR lines, so its trace keeps its head; null
100+
* when every line is an ERROR, where a head would keep the oldest errors.
101+
*/
102+
private fun newestErrorRunStart(entries: List<LogEntry>): Int? {
103+
var start = entries.indexOfLast { it.level == LogLevel.ERROR }.takeIf { it >= 0 } ?: return null
104+
while (start > 0 && entries[start - 1].level == LogLevel.ERROR) start--
105+
return start.takeUnless { it == 0 && entries.last().level == LogLevel.ERROR }
106+
}
107+
108+
private fun isCrashLine(entry: LogEntry): Boolean =
109+
CRASH_MARKERS.any { entry.text.contains(it, ignoreCase = true) }
110+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.itsaky.androidide.plugins.aicore.tool.handlers
2+
3+
/** Marks where a window dropped text, shared so the build and log windows read the same. */
4+
internal const val TRUNCATION_MARKER = "...[truncated]..."
5+
6+
/** The slice of a build or app/IDE log handed to the model, and whether it starts at an error. */
7+
internal data class OutputWindow(
8+
val text: String,
9+
val anchoredOnError: Boolean,
10+
)

‎plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadBuildOutputHandler.kt‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,6 @@ import com.itsaky.androidide.plugins.aicore.tool.ToolHandler
77
import com.itsaky.androidide.plugins.services.IdeBuildService
88
import kotlinx.coroutines.CancellationException
99

10-
/** The slice of a build log handed to the model, and whether it starts at the first error. */
11-
internal data class OutputWindow(
12-
val text: String,
13-
val anchoredOnError: Boolean,
14-
)
15-
1610
/**
1711
* Handler for reading the current build output.
1812
*/
@@ -80,8 +74,6 @@ class ReadBuildOutputHandler(
8074
/** Maximum characters of build log handed to the model. */
8175
internal const val MAX_OUTPUT_CHARS = 8000
8276

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

@@ -112,7 +104,7 @@ class ReadBuildOutputHandler(
112104
val text = if (overflows) body.takeLast(MAX_OUTPUT_CHARS) else body
113105
val dropped = overflows || (errorOffset ?: 0) > 0
114106
return OutputWindow(
115-
text = if (dropped) TRUNCATION_MARKER + text else text,
107+
text = if (dropped) "$TRUNCATION_MARKER\n$text" else text,
116108
anchoredOnError = errorOffset != null && !overflows,
117109
)
118110
}

0 commit comments

Comments
 (0)