diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt index 2a2bb0e1e..2a424b70c 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt @@ -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): 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}" diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AgentEntries.kt b/app/src/main/java/com/droiddeck/launcher/ui/AgentEntries.kt index f8979d82d..63dde3887 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AgentEntries.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AgentEntries.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt index 580538981..329cca086 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt @@ -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() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8febbb871..70527d57f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1164,6 +1164,8 @@ Failed: %1$s %1$s ended (exit %2$d) Stopped + Ended without a reply + Retry Setting up the agent Rebuild my Lab build %1$s for the installed version of DroidDeck and restart on it. %1$s in ยท %2$s out diff --git a/app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt b/app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt index f5950bdfa..371c3576c 100644 --- a/app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt @@ -121,6 +121,13 @@ class AgentHostTest { assertEquals(emptyList(), 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)