Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
7 changes: 7 additions & 0 deletions plugins/AI-Core/src/main/assets/prompts/agent_loop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ agent_loop:
Your reply called no tool, so the run is still open. If every part of the request is now
answered, call the "{{TERMINAL_TOOL}}" tool with a one-line summary — do not repeat what you
already wrote. Otherwise do the next part now.
# Sent once when a run tries to finish while its last attempt to change a file failed and no change
# has succeeded since: a small model tends to answer as if the change had been made.
unapplied_write: >-
Nothing was changed: your last attempt to change a file failed, and no change has succeeded
since. Do not tell the user it was done. Read the file with read_file, then repeat the change
with the text copied exactly from it; or, if you cannot, call the "{{TERMINAL_TOOL}}" tool and
say plainly that the change was not made.
# Sent once when a run that had to check the web first answered without searching; TOOL is the
# search tool. A backend that forces the call never sends it (see VerificationPolicy).
required_tool: >-
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class ToolResultsPrompt(
/** The turn sent after a reply that ran tools and ended without the terminal tool. */
suspend fun unfinished(): String = renderUnfinished(config.config(), terminalTool)

/** The turn sent after a reply that tried to finish while its last file change had failed. */
suspend fun unappliedWrite(): String = renderUnappliedWrite(config.config(), terminalTool)

/** The turn sent after a reply that answered before calling [tool], which the run had to call first. */
suspend fun requiredTool(tool: String): String = renderRequiredTool(config.config(), terminalTool, tool)

Expand Down Expand Up @@ -93,6 +96,19 @@ class ToolResultsPrompt(
mapOf(PromptVariables.TERMINAL_TOOL to terminalTool),
)

/**
* Renders the turn that tells a run its last attempt to change a file failed.
*
* @param config the loaded prompt config.
* @param terminalTool the name of the tool that ends a run by answering the user.
* @return the turn to add to the transcript.
*/
fun renderUnappliedWrite(config: AgentPromptConfig, terminalTool: String): String =
PromptTemplateEngine.render(
config.agentLoop.unappliedWrite,
mapOf(PromptVariables.TERMINAL_TOOL to terminalTool),
)

/**
* Renders the turn that asks a run which answered too early to call [tool] first.
*
Expand All @@ -117,6 +133,7 @@ class ToolResultsPrompt(
val renders: List<() -> String> = CHECK_BATCHES.map { (calls, results) ->
{ render(config, CHECK_TERMINAL_TOOL, CHECK_CHAR_LIMIT, calls, results) }
} + { renderUnfinished(config, CHECK_TERMINAL_TOOL) } +
{ renderUnappliedWrite(config, CHECK_TERMINAL_TOOL) } +
{ renderRequiredTool(config, CHECK_TERMINAL_TOOL, CHECK_REQUIRED_TOOL) }
return renders.mapNotNull { check ->
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ data class AgentPromptConfig(
* @property afterFailure what to do next otherwise.
* @property unfinished the turn after a reply that ran tools and then ended without the
* terminal tool.
* @property unappliedWrite the turn after a reply that tried to finish while the last attempt to
* change a file had failed and none had succeeded since.
* @property requiredTool the turn after a reply that answered before calling the tool the run
* had to call first, such as a web search before a code review.
*/
Expand All @@ -121,6 +123,7 @@ data class AgentPromptConfig(
val afterSuccess: PromptText,
val afterFailure: PromptText,
val unfinished: PromptText,
val unappliedWrite: PromptText,
val requiredTool: PromptText,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ object AgentPromptConfigParser : PromptConfigParser<AgentPromptConfig> {
afterSuccess = text("after_success"),
afterFailure = text("after_failure"),
unfinished = text("unfinished"),
unappliedWrite = text("unapplied_write"),
requiredTool = text("required_tool"),
)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import com.itsaky.androidide.plugins.services.LlmInferenceService.ChatMessage.Ro
* [terminalTool]; null ends the run on that reply instead.
* @param requiredToolTurn words the turn sent when a run told to call a tool first tries to finish
* without it; receives the tool's name. Null never asks.
* @param unappliedWriteTurn words the turn sent when a run answers while its last attempt to change
* a file failed and none has succeeded since; null lets that answer end the run.
*/
class AgentLoop(
private val formatToolResults: ToolResultsFormatter,
Expand All @@ -27,6 +29,7 @@ class AgentLoop(
private val terminalTool: String? = null,
private val unfinishedTurn: (suspend () -> String)? = null,
private val requiredToolTurn: (suspend (String) -> String)? = null,
private val unappliedWriteTurn: (suspend () -> String)? = null,
) {

companion object {
Expand Down Expand Up @@ -156,6 +159,23 @@ class AgentLoop(
* @param tool the tool the run had to call.
*/
suspend fun onRequiredToolSkipped(turn: Int, tool: String) {}

/**
* The run answered while its last attempt to change a file had failed and no change had
* succeeded since, so it was told the change was not made rather than being ended on a
* reply that probably says it was.
*
* @param turn 1-based turn index.
*/
suspend fun onUnappliedWriteAnswer(turn: Int) {}

/**
* The run ended with a failed file change still outstanding after it had been told, so
* whatever the last reply says, the change was not made.
*
* @param turn 1-based turn index.
*/
suspend fun onChangeNotMade(turn: Int) {}
}

/** Why the loop stopped. */
Expand Down Expand Up @@ -227,6 +247,9 @@ class AgentLoop(
// Once a tool has run, only the terminal tool finishes the run; see [unfinishedTurn].
var toolsRan = false
var askedToFinish = false
// True from a failed (not user-denied) file change until a later one succeeds.
var writeUnapplied = false
Comment thread
davidschachterADFA marked this conversation as resolved.
Resolved
var warnedOfUnappliedWrite = false
Comment thread
davidschachterADFA marked this conversation as resolved.
Resolved
// Cleared once the tool runs or has been asked for, so a model that refuses it still stops.
var requiredPending = requiredTool != null && requiredToolTurn != null
while (turn < maxIterations) {
Expand Down Expand Up @@ -283,12 +306,21 @@ class AgentLoop(
askForRequiredTool(turn, requiredTool!!, history, events)
continue
}
terminalTool?.let { tt ->
val terminal = calls.firstOrNull { isTerminalToolName(it.name, tt) }
if (terminal != null && realCalls.isEmpty()) {
events.onFinalAnswer(turn, respondMessageOf(terminal.args).orEmpty())
return Result(turn, StopReason.COMPLETED)
val terminal = terminalTool?.let { tt -> calls.firstOrNull { isTerminalToolName(it.name, tt) } }
if (terminal != null && realCalls.isEmpty()) {
if (writeUnapplied && unappliedWriteTurn != null) {
Comment thread
davidschachterADFA marked this conversation as resolved.
Resolved
if (!warnedOfUnappliedWrite) {
warnedOfUnappliedWrite = true
events.onUnappliedWriteAnswer(turn)
history.add(ChatMessage(Role.USER, unappliedWriteTurn.invoke()))
continue
}
// Told once and answered again: the user's change is still not made.
events.onChangeNotMade(turn)
return Result(turn, StopReason.ABANDONED)
}
events.onFinalAnswer(turn, respondMessageOf(terminal.args).orEmpty())
return Result(turn, StopReason.COMPLETED)
}

when (progress.inspect(realCalls)) {
Expand All @@ -297,6 +329,11 @@ class AgentLoop(
return Result(turn, StopReason.CYCLING)
}
ToolCallProgressGuard.Verdict.ASSUME_COMPLETE -> {
// A repeated look is not completion while the change the user asked for failed.
if (writeUnapplied) {
events.onChangeNotMade(turn)
return Result(turn, StopReason.ABANDONED)
}
events.onRepeatAfterSuccess(turn)
return Result(turn, StopReason.COMPLETED)
}
Expand All @@ -312,6 +349,9 @@ class AgentLoop(
toolsRan = true
askedToFinish = false
progress.recordResults(results)
realCalls.zip(results).filter { (call, _) -> changesPaths(call) }.forEach { (_, result) ->
Comment thread
davidschachterADFA marked this conversation as resolved.
Resolved
if (result.success) writeUnapplied = false else if (!result.denied) writeUnapplied = true
Comment thread
davidschachterADFA marked this conversation as resolved.
Resolved
}
events.onToolResults(turn, realCalls, results)
history.add(ChatMessage(Role.USER, formatToolResults.format(realCalls, results)))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.itsaky.androidide.plugins.aicore.tool.handlers.edit.AtomicFileWriter
import com.itsaky.androidide.plugins.aicore.tool.handlers.edit.EditTargetResolver
import com.itsaky.androidide.plugins.aicore.tool.handlers.edit.EditorBufferApplier
import com.itsaky.androidide.plugins.aicore.tool.handlers.edit.FileTextMatcher
import com.itsaky.androidide.plugins.aicore.tool.handlers.edit.NearMatchLines
import com.itsaky.androidide.plugins.aicore.logging.AgentTrace
import com.itsaky.androidide.plugins.aicore.tool.parseToolBoolean
import com.itsaky.androidide.plugins.services.IdeEditorService
Expand Down Expand Up @@ -236,9 +237,6 @@ class EditFileHandler(
"$ARG_OLD/$ARG_NEW must be under $MAX_ARG_CHARS characters — edit a smaller region"
)
}
if (oldString == newString) {
return reject(identicalArgsRejection(oldString))
}

val target = when (val resolution = EditTargetResolver.resolve(filePath)) {
is EditTargetResolver.Target.Rejected -> {
Expand Down Expand Up @@ -281,6 +279,13 @@ class EditFileHandler(
)
}

// After the read, so the rejection can show the lines the model should have copied.
if (oldString == newString) {
return reject(
identicalArgsRejection(oldString) + NearMatchLines.render(NearMatchLines.find(original, oldString))
)
}

val match = FileTextMatcher.match(original, oldString, newString)
if (match is FileTextMatcher.Match.NotFound) {
// The dominant local-model failure: whitespace or escaping drift from the real file.
Expand All @@ -293,7 +298,8 @@ class EditFileHandler(
}
return reject(
"$ARG_OLD not found in $resolvedPath — read the file first and copy the target " +
"text exactly, including indentation and line breaks"
"text exactly, including indentation and line breaks" +
NearMatchLines.render(NearMatchLines.find(original, oldString))
)
}
val found = match as FileTextMatcher.Match.Found
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.itsaky.androidide.plugins.aicore.tool.handlers.edit

/**
* Finds the lines of a file that a failed `old_string` was probably aiming at, so a rejection can
* show them. A small model that guesses `old_string` and is told only "not found" guesses again;
* shown the real line, it can copy it. Matching is by shared words, never by regex, for the same
* reason as [FileTextMatcher].
*/
object NearMatchLines {

/** One candidate line, with its 1-based number and its exact text (no line ending). */
data class Line(val number: Int, val text: String)

private const val MAX_LINES = 5
private const val MAX_LINE_CHARS = 200
private const val MIN_WORD_CHARS = 3
private val WORD = Regex("[A-Za-z0-9_]{$MIN_WORD_CHARS,}")

/**
* The lines of [text] sharing the most distinct words with [snippet].
* @param text the file contents the edit was matched against.
* @param snippet the `old_string` that was not found or changed nothing.
* @return up to [MAX_LINES] lines, best match first and file order among equals; empty when
* the snippet has no word of [MIN_WORD_CHARS] or more characters or no line shares one.
*/
fun find(text: String, snippet: String): List<Line> {
val words = WORD.findAll(snippet).map { it.value.lowercase() }.distinct().toList()
if (words.isEmpty()) return emptyList()
return text.lineSequence()
.mapIndexed { index, raw -> Line(index + 1, raw.trimEnd('\r').take(MAX_LINE_CHARS)) }
.filter { it.text.isNotBlank() }
.map { line ->
val lower = line.text.lowercase()
line to words.count { lower.contains(it) }
}
.filter { (_, score) -> score > 0 }
.sortedByDescending { (_, score) -> score }
.take(MAX_LINES)
.map { (line, _) -> line }
.toList()
}

/**
* Renders [lines] for a rejection message, keeping leading and trailing spaces visible.
* @param lines the result of [find].
* @return the text to append to a rejection, or an empty string when there are no lines.
*/
fun render(lines: List<Line>): String {
if (lines.isEmpty()) return ""
val body = lines.joinToString("\n") { "line ${it.number}: \"${it.text}\"" }
return "\nClosest lines in the file (the exact text is between the outer double quotes, " +
"spaces included):\n$body"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ internal class AgentRunReporter(
/** The model kept re-issuing one batch that was not working. */
suspend fun repeatedCalls()

/** The run ended with a failed file change never redone, whatever the last reply says. */
suspend fun changeNotMade()

/** The model kept circling actions it had already taken. */
suspend fun noProgress()
}
Expand Down Expand Up @@ -86,6 +89,15 @@ internal class AgentRunReporter(
onAskedToFinish()
}

override suspend fun onUnappliedWriteAnswer(turn: Int) {
AgentTrace.refusal("LOOP", "turn=$turn", "answered with a failed file change outstanding; told it was not made")
}

override suspend fun onChangeNotMade(turn: Int) {
AgentTrace.refusal("LOOP", "turn=$turn", "ended with a failed file change never redone")
notices.changeNotMade()
}

// Reaching this means the backend did not force the call; see VerificationPolicy.
override suspend fun onRequiredToolSkipped(turn: Int, tool: String) {
AgentTrace.stage("VERIFY", "turn=$turn asked-for=$tool (answered without it)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ class ChatViewModel(
terminalTool = RESPOND_TOOL,
unfinishedTurn = toolResultsPrompt::unfinished,
requiredToolTurn = toolResultsPrompt::requiredTool,
unappliedWriteTurn = toolResultsPrompt::unappliedWrite,
)
val toolExecutionTracker = ToolExecutionTracker()

Expand Down Expand Up @@ -2035,6 +2036,9 @@ class ChatViewModel(
override suspend fun repeatedCalls() =
addSystemMessage(str(R.string.agent_repeated_calls), MessageStatus.SENT)

override suspend fun changeNotMade() =
addSystemMessage(str(R.string.agent_change_not_made), MessageStatus.SENT)

override suspend fun noProgress() =
addSystemMessage(str(R.string.agent_no_progress), MessageStatus.SENT)
}
Expand Down
1 change: 1 addition & 0 deletions plugins/AI-Core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@
<!-- Agent loop / chat runtime messages -->
<string name="agent_max_steps_reached">Reached the %1$d-step limit. Ask me to continue if the task isn\'t finished.</string>
<string name="agent_repeated_calls">Stopped: the model kept requesting the same action. Try rephrasing your request.</string>
<string name="agent_change_not_made">The change was not made: the file edit failed and the assistant did not manage to redo it, whatever its last message says. Try again, or ask for a smaller change.</string>
<string name="agent_no_progress">Stopped: the model kept redoing actions it had already taken instead of making progress. Try rephrasing your request, or asking for a smaller step.</string>
<string name="chat_compacted">Earlier messages were summarized to stay within the model\'s limit.</string>
<string name="chat_compaction_failed">Earlier messages could not be summarized, so only the most recent ones were kept to stay within the model\'s limit.</string>
Expand Down
Loading
Loading