mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
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:
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() }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user