feat(agent): a turn that ends without a reply says so and offers Retry, the resume is retried once, and each turn's raw output is kept

This commit is contained in:
Kurt Himebauch committed 2026-11-10 00:40:36 -05:00
1 parent 3e349d4c78
commit 9e3814b546
5 files changed
+51 -3

No files matched your search

@@ -103,6 +103,7 @@ object AgentHost {
/** Counts turns, so a process that starts after its turn was stopped is not adopted by the next one. */
private var generation = 0
private var labProcess: Process? = null
private var retriedResume = false
/** The running Lab build was asked for by the current turn, so stopping the turn stops it. */
private var labFromTurn = false
private var conversation: String? = null
@@ -253,13 +254,16 @@ object AgentHost {
main.post { if (generation == gen && state == State.WORKING && turn == null) turn = started else AgentProcesses.killTree(started.pid) }
child.outputStream.close()
var ended = false
val raw = runCatching { File(app.filesDir, TURN_LOG).apply { parentFile?.mkdirs() }.bufferedWriter() }.getOrNull()
runCatching {
child.inputStream.bufferedReader().forEachLine { line ->
runCatching { raw?.apply { write(line); newLine(); flush() } }
val event = current.parse(line) ?: return@forEachLine
if (event.entries.any { it is Entry.Finished }) ended = true
main.post { if (turn === started) apply(app, event) }
}
}
runCatching { raw?.close() }
val status = runCatching { child.waitFor() }.getOrDefault(-1)
main.post {
if (turn === started) {
@@ -366,6 +370,13 @@ object AgentHost {
unseen = true
failed = error != null
AgentHostService.stop(app)
// A resumed turn that the CLI ended without a reply is tried once more.
val asked = entries.lastOrNull { it is Entry.Asked } as? Entry.Asked
if (!stopped && error == null && answerless(entries) && asked?.text == app.getString(R.string.agent_resumed) && !retriedResume) {
retriedResume = true
main.postDelayed({ if (state == State.IDLE) send(app, asked.text) }, RETRY_MS)
return
}
if (!stopped && queue.isNotEmpty()) {
val next = queue.removeAt(0)
send(app, next.text, next.files)
@@ -377,6 +388,21 @@ object AgentHost {
chat?.let { AgentChats.append(AgentChats.file(app, it), entry) }
}
/** The last turn ended without saying or doing anything. */
fun answerless(entries: List<Entry>): Boolean {
val start = entries.indexOfLast { it is Entry.Asked }
if (start < 0) return false
val turn = entries.subList(start + 1, entries.size)
return turn.lastOrNull() is Entry.Finished && turn.none { it is Entry.Said || it is Entry.Used || it is Entry.Thought } &&
(turn.last() as Entry.Finished).let { it.error == null && !it.stopped }
}
/** Sends the last message again, after a turn that ended without a reply. */
fun retry(context: Context) {
val asked = entries.lastOrNull { it is Entry.Asked } as? Entry.Asked ?: return
send(context, asked.text, asked.files)
}
/** The tool call still running in this turn, if any. */
fun running(): Entry.Used? {
if (state != State.WORKING) return null
@@ -444,6 +470,9 @@ object AgentHost {
private const val RESUME = "agent/resume"
private const val LAB_REQUEST = "agent/lab-request"
private const val LAB_CHAT_KEY = "lab_chat"
/** The raw output of the latest turn, for working out what a CLI did when the chat shows nothing. */
private const val TURN_LOG = "agent/turn.log"
private const val RETRY_MS = 3_000L
private fun modelKey(harness: AgentHarness) = "model_${harness.id}"
@@ -53,10 +53,11 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.agent.AgentAttachments
import com.droiddeck.launcher.agent.AgentHost
import com.droiddeck.launcher.agent.AgentHost.Entry
@Composable
internal fun AgentEntry(entry: Entry, running: Boolean) {
internal fun AgentEntry(entry: Entry, running: Boolean, last: Boolean = false) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
when (entry) {
@@ -71,7 +72,7 @@ internal fun AgentEntry(entry: Entry, running: Boolean) {
is Entry.Thought -> AgentThought(entry.text, live = false)
is Entry.Used -> if (entry.tool == "TodoWrite") AgentTodos(entry.input) else AgentToolCall(entry, running)
is Entry.Result -> Unit
is Entry.Finished -> AgentTurnEnd(entry)
is Entry.Finished -> if (last && AgentHost.answerless(AgentHost.entries)) AgentNoReply() else AgentTurnEnd(entry)
}
}
@@ -95,6 +96,15 @@ private fun AgentTurnEnd(entry: Entry.Finished) {
modifier = Modifier.padding(horizontal = 4.dp))
}
@Composable
private fun AgentNoReply() {
val context = LocalContext.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(horizontal = 4.dp)) {
Text(stringResource(R.string.agent_no_reply), fontSize = 13.sp, color = AttentionAmber)
SecondaryButton(stringResource(R.string.agent_retry), compact = true) { AgentHost.retry(context) }
}
}
internal fun tokens(n: Long) = if (n >= 1000) "${n / 1000}k" else n.toString()
@Composable
@@ -175,7 +175,7 @@ private fun AgentConversation(modifier: Modifier) {
LazyColumn(modifier.fillMaxWidth(), state = list, verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(AgentHost.entries.size) { i ->
val entry = AgentHost.entries[i]
AgentEntry(entry, running = working && i > start && entry is Entry.Used && entry.output == null)
AgentEntry(entry, running = working && i > start && entry is Entry.Used && entry.output == null, last = !working && i == AgentHost.entries.lastIndex)
}
if (working) item { AgentLive() }
}
+2
View File
@@ -1164,6 +1164,8 @@
<string name="agent_failed">Failed: %1$s</string>
<string name="agent_exited">%1$s ended (exit %2$d)</string>
<string name="agent_stopped">Stopped</string>
<string name="agent_no_reply">Ended without a reply</string>
<string name="agent_retry">Retry</string>
<string name="agent_setting_up">Setting up the agent</string>
<string name="agent_rebuild_lab">Rebuild my Lab build %1$s for the installed version of DroidDeck and restart on it.</string>
<string name="agent_tokens">%1$s in · %2$s out</string>
@@ -121,6 +121,13 @@ class AgentHostTest {
assertEquals(emptyList<Entry>(), AgentChats.read(File(file.path + ".missing")).entries)
}
@Test fun aTurnThatEndsWithNothingIsAnswerless() {
assertTrue(AgentHost.answerless(listOf(Entry.Said("x"), Entry.Asked("go on"), Entry.Finished(null))))
assertFalse(AgentHost.answerless(listOf(Entry.Asked("go on"), Entry.Said("ok"), Entry.Finished(null))))
assertFalse(AgentHost.answerless(listOf(Entry.Asked("go on"), Entry.Finished("boom"))))
assertFalse(AgentHost.answerless(listOf(Entry.Asked("go on"), Entry.Finished(null, stopped = true))))
}
@Test fun longOutputKeepsItsEnd() {
val clipped = AgentChats.clip("a".repeat(30_000) + "END")
assertTrue(clipped.endsWith("END") && clipped.length < 30_000)