M1+M2: gateway core loop + agent transparency

M1 (gateway core loop / text round-trip):
- WS server (ws_server.py): bind, hello auth (constant-time), hello.ack, heartbeat, connection registry
- pairing.py: token generation, pairing store, QR payload
- adapter.py: send() -> message frame; inbound message.send -> MessageEvent -> handle_message
- app: Connect screen, GatewayClient (connect + reconnect), ChatScreen send/render, SecureStore (Android/Desktop)
- tests/ws_probe.py: probe harness driving a real turn

M2 (streaming + reasoning + tools + commentary):
- protocol.py: M2 frame types (message.start/update/stop, tool.start/progress/end, commentary)
- adapter.py: per-chat turn-state machine; classify outbound into frames; _split_reasoning; tool-line parsing
- reasoning in streaming: capture via on_stream_delta hook (kind=reasoning, gated by plugins.stream_reasoning_deltas) with a FIFO barrier, attach to message.stop
- app: live streaming bubble, ReasoningBlock (collapse + copy), ToolCard (Everything/Truncated/Nothing), dimmed commentary, typing
- docs/14-milestones.md: M1/M2 marked done; reasoning note corrected
This commit is contained in:
ARIA committed 2026-08-19 13:56:19 +02:00
1 parent 59acf66c89
commit 218c50d688
21 files changed
+3429 -121

No files matched your search

@@ -7,6 +7,7 @@
<application <application
android:label="Iris" android:label="Iris"
android:allowBackup="true" android:allowBackup="true"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.NoActionBar"> android:theme="@android:style/Theme.Material.NoActionBar">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -4,12 +4,14 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import iris.IrisApp import iris.IrisApp
import iris.platform.AndroidSecureStore
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val store = AndroidSecureStore(applicationContext)
setContent { setContent {
IrisApp() IrisApp(store)
} }
} }
} }
+1
View File
@@ -2,6 +2,7 @@ plugins {
kotlin("multiplatform") version "2.1.0" apply false kotlin("multiplatform") version "2.1.0" apply false
kotlin("android") version "2.1.0" apply false kotlin("android") version "2.1.0" apply false
kotlin("jvm") version "2.1.0" apply false kotlin("jvm") version "2.1.0" apply false
kotlin("plugin.serialization") version "2.1.0" apply false
id("com.android.application") version "8.7.3" apply false id("com.android.application") version "8.7.3" apply false
id("com.android.library") version "8.7.3" apply false id("com.android.library") version "8.7.3" apply false
id("org.jetbrains.compose") version "1.7.3" apply false id("org.jetbrains.compose") version "1.7.3" apply false
@@ -3,9 +3,11 @@ package iris.desktop
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import iris.IrisApp import iris.IrisApp
import iris.platform.DesktopSecureStore
fun main() = application { fun main() = application {
val store = DesktopSecureStore()
Window(onCloseRequest = ::exitApplication, title = "Iris") { Window(onCloseRequest = ::exitApplication, title = "Iris") {
IrisApp() IrisApp(store)
} }
} }
+9
View File
@@ -1,11 +1,15 @@
plugins { plugins {
kotlin("multiplatform") kotlin("multiplatform")
kotlin("plugin.serialization")
id("com.android.library") id("com.android.library")
id("org.jetbrains.compose") id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
} }
val composeVersion = "1.7.3" val composeVersion = "1.7.3"
val coroutinesVersion = "1.9.0"
val serializationVersion = "1.7.3"
val okhttpVersion = "4.12.0"
kotlin { kotlin {
androidTarget() androidTarget()
@@ -17,6 +21,11 @@ kotlin {
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion") implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
implementation("org.jetbrains.compose.material3:material3:$composeVersion") implementation("org.jetbrains.compose.material3:material3:$composeVersion")
implementation("org.jetbrains.compose.ui:ui:$composeVersion") implementation("org.jetbrains.compose.ui:ui:$composeVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
// OkHttp is a JVM library; both targets here are JVM-based
// (androidTarget + jvm("desktop")), so it resolves on both.
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
} }
} }
} }
@@ -0,0 +1,50 @@
package iris.platform
import android.content.Context
import android.os.Build
import iris.data.SecureStore
import java.util.UUID
/**
* Android pairing storage. M1: SharedPreferences (dev). M5 moves the token
* to EncryptedSharedPreferences per docs/10 §10.2.
*/
class AndroidSecureStore(context: Context) : SecureStore {
private val prefs = context.applicationContext.getSharedPreferences("iris", Context.MODE_PRIVATE)
override var serverUrl: String
get() = prefs.getString(KEY_URL, "").orEmpty()
set(value) = prefs.edit().putString(KEY_URL, value.trim()).apply()
override var token: String
get() = prefs.getString(KEY_TOKEN, "").orEmpty()
set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply()
override val deviceId: String
get() {
var id = prefs.getString(KEY_DEVICE_ID, null)
if (id.isNullOrBlank()) {
id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
}
return id
}
override val deviceName: String
get() = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
override fun savePairing(url: String, token: String) {
serverUrl = url
this.token = token
}
override fun clear() {
prefs.edit().remove(KEY_URL).remove(KEY_TOKEN).apply()
}
private companion object {
const val KEY_URL = "server_url"
const val KEY_TOKEN = "token"
const val KEY_DEVICE_ID = "device_id"
}
}
@@ -1,52 +1,59 @@
package iris package iris
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import iris.data.SecureStore
import androidx.compose.ui.unit.sp import iris.net.GatewayClient
import iris.state.IrisController
import iris.ui.screens.ChatScreen
import iris.ui.screens.ConnectScreen
private val IrisDark = darkColorScheme(
background = Color(0xFF1B1E28),
surface = Color(0xFF222634),
onBackground = Color(0xFFE8EAF0),
onSurface = Color(0xFFE8EAF0),
primary = Color(0xFF4F7CFF),
)
/** /**
* Root composable shared by the Android and Desktop shells. * Root composable shared by the Android and Desktop shells.
* *
* M0: a visible placeholder to confirm the Compose stack renders. M1+ * M1: routes between the Connect screen (unpaired / auth failed) and the
* replaces this with the Connect screen, chat UI, and the rest of the app * Chat screen (paired). Later milestones add the channel list, search,
* (see docs/10-android-app.md). * settings, and media (docs/10-android-app.md).
*/ */
@Composable @Composable
fun IrisApp() { fun IrisApp(store: SecureStore) {
MaterialTheme { val controller = remember(store) { IrisController(store) }
Surface(modifier = Modifier.fillMaxSize()) { DisposableEffect(controller) {
Box( onDispose { controller.dispose() }
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF1B1E28)),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(72.dp)
.background(Color(0xFF4F7CFF), CircleShape),
contentAlignment = Alignment.Center,
) {
Text(text = "I", color = Color.White, fontSize = 40.sp)
}
Spacer(modifier = Modifier.height(16.dp))
Text(text = "Iris × Hermes", color = Color.White, fontSize = 22.sp)
} }
val state by controller.client.state.collectAsState()
MaterialTheme(colorScheme = IrisDark) {
Surface(modifier = Modifier.background(IrisDark.background)) {
val s = state
when (s) {
GatewayClient.State.Disconnected ->
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
is GatewayClient.State.AuthFailed ->
ConnectScreen(
controller,
prefillUrl = store.serverUrl,
prefillToken = store.token,
initialError = "Pairing rejected: ${s.message}",
)
else -> ChatScreen(controller)
} }
} }
} }
@@ -0,0 +1,263 @@
package iris.data
import iris.protocol.CommentaryPayload
import iris.protocol.Frame
import iris.protocol.MessagePayload
import iris.protocol.MessageStartPayload
import iris.protocol.MessageStopPayload
import iris.protocol.MessageUpdatePayload
import iris.protocol.ROLE_ASSISTANT
import iris.protocol.ROLE_USER
import iris.protocol.TYPE_COMMENTARY
import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE
import iris.protocol.TYPE_TOOL_END
import iris.protocol.TYPE_TOOL_PROGRESS
import iris.protocol.TYPE_TOOL_START
import iris.protocol.ToolEndPayload
import iris.protocol.ToolProgressPayload
import iris.protocol.ToolStartPayload
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.JsonElement
import kotlin.random.Random
/**
* In-memory chat cache (M2). The server is authoritative; this is a cache that
* reconciles on server frames. M2 adds streaming bubbles (message.start/
* update/stop), structured tool cards (tool.start/progress/end), and dimmed
* commentary beats, all interleaved in chronological order as [ChatItem]s.
* SQLDelight persistence lands with the channel/message schema later.
*/
sealed interface ChatItem {
val id: String
}
/** A chat message (user / assistant / commentary / streaming bubble). */
data class MessageItem(
override val id: String,
val role: String,
val text: String,
val ts: Long,
val pending: Boolean = false,
val reasoning: String? = null,
val isCommentary: Boolean = false,
val streaming: Boolean = false,
val model: String? = null,
val tokens: Int? = null,
) : ChatItem
/** A structured tool-activity card (spinner until [done]). */
data class ToolItem(
override val id: String,
val index: Int,
val name: String,
val preview: String? = null,
val args: JsonElement? = null,
val note: String? = null,
val done: Boolean = false,
val ok: Boolean = true,
val duration: Double? = null,
val outputPreview: String? = null,
) : ChatItem
class ChatStore {
private val _items = MutableStateFlow<List<ChatItem>>(emptyList())
val items: StateFlow<List<ChatItem>> = _items.asStateFlow()
private var localSeq = 0
private var toolSeq = 0
/** Optimistic add: show the user's message immediately (pending). */
fun addPending(text: String): String {
localSeq++
val id = "local_$localSeq"
_items.value = _items.value +
MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true)
return id
}
/** Reconcile a server frame into the cache. */
fun onFrame(frame: Frame) {
when (frame.type) {
TYPE_MESSAGE -> onMessage(frame)
TYPE_MESSAGE_START -> onMessageStart(frame)
TYPE_MESSAGE_UPDATE -> onMessageUpdate(frame)
TYPE_MESSAGE_STOP -> onMessageStop(frame)
TYPE_TOOL_START -> onToolStart(frame)
TYPE_TOOL_PROGRESS -> onToolProgress(frame)
TYPE_TOOL_END -> onToolEnd(frame)
TYPE_COMMENTARY -> onCommentary(frame)
else -> Unit
}
}
// ── message (final / standalone, incl. non-streaming + user echo) ─────
private fun onMessage(frame: Frame) {
val p = frame.payloadAs<MessagePayload>() ?: return
val list = _items.value.toMutableList()
val byId = list.indexOfFirst { it.id == p.messageId }
if (byId >= 0) {
list[byId] = (list[byId] as? MessageItem)?.copy(
text = p.text,
reasoning = p.reasoning,
pending = false,
streaming = false,
model = p.model,
tokens = p.tokens,
ts = p.ts ?: 0,
) ?: return
_items.value = list
return
}
if (p.role == ROLE_USER) {
// Replace the matching optimistic pending bubble (server echo).
val pendingIdx = list.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
if (pendingIdx >= 0) {
list[pendingIdx] = MessageItem(
id = p.messageId, role = p.role, text = p.text,
ts = p.ts ?: 0, reasoning = p.reasoning,
)
_items.value = list
return
}
}
list.add(
MessageItem(
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
),
)
_items.value = list
}
// ── message.start (open a live streaming bubble) ──────────────────────
private fun onMessageStart(frame: Frame) {
val p = frame.payloadAs<MessageStartPayload>() ?: return
val list = _items.value.toMutableList()
if (list.any { it.id == p.messageId }) return
list.add(
MessageItem(
id = p.messageId, role = p.role, text = "",
ts = 0, streaming = true,
),
)
_items.value = list
}
// ── message.update (replace live bubble text; full snapshot) ──────────
private fun onMessageUpdate(frame: Frame) {
val p = frame.payloadAs<MessageUpdatePayload>() ?: return
val list = _items.value.toMutableList()
val idx = list.indexOfFirst { it.id == p.messageId }
if (idx < 0) return
val cur = list[idx] as? MessageItem ?: return
list[idx] = cur.copy(text = p.text)
_items.value = list
}
// ── message.stop (finalize the live bubble) ───────────────────────────
private fun onMessageStop(frame: Frame) {
val p = frame.payloadAs<MessageStopPayload>() ?: return
val list = _items.value.toMutableList()
val idx = list.indexOfFirst { it.id == p.messageId }
if (idx < 0) {
// No live bubble (e.g. missed start) — materialize a final one.
list.add(
MessageItem(
id = p.messageId, role = ROLE_ASSISTANT, text = p.finalText,
ts = p.ts ?: 0, reasoning = p.reasoning,
model = p.model, tokens = p.tokens,
),
)
_items.value = list
return
}
val cur = list[idx] as? MessageItem ?: return
list[idx] = cur.copy(
text = p.finalText,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
streaming = false,
ts = p.ts ?: cur.ts,
)
_items.value = list
}
// ── tool.start (new tool card) ────────────────────────────────────────
private fun onToolStart(frame: Frame) {
val p = frame.payloadAs<ToolStartPayload>() ?: return
toolSeq++
val id = "tool_$toolSeq"
val list = _items.value.toMutableList()
list.add(
ToolItem(
id = id, index = p.index, name = p.name,
preview = p.preview, args = p.args,
),
)
_items.value = list
}
// ── tool.progress (in-progress note) ──────────────────────────────────
private fun onToolProgress(frame: Frame) {
val p = frame.payloadAs<ToolProgressPayload>() ?: return
val list = _items.value.toMutableList()
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
if (idx < 0) return
val cur = list[idx] as ToolItem
list[idx] = cur.copy(note = p.note)
_items.value = list
}
// ── tool.end (mark the tool card complete) ────────────────────────────
private fun onToolEnd(frame: Frame) {
val p = frame.payloadAs<ToolEndPayload>() ?: return
val list = _items.value.toMutableList()
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
if (idx < 0) return
val cur = list[idx] as ToolItem
list[idx] = cur.copy(
done = true, ok = p.ok, duration = p.duration,
outputPreview = p.outputPreview,
)
_items.value = list
}
// ── commentary (dimmed interim beat) ──────────────────────────────────
private fun onCommentary(frame: Frame) {
val p = frame.payloadAs<CommentaryPayload>() ?: return
val list = _items.value.toMutableList()
if (list.any { it.id == p.messageId }) return
list.add(
MessageItem(
id = p.messageId, role = ROLE_ASSISTANT, text = p.text,
ts = 0, isCommentary = true,
),
)
_items.value = list
}
fun clear() {
_items.value = emptyList()
}
companion object {
fun randomId(prefix: String): String =
"${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
}
}
@@ -0,0 +1,23 @@
package iris.data
/**
* Pairing settings storage. The token is a secret: platform actuals keep it
* in secure storage (EncryptedSharedPreferences on Android — M5; plain
* SharedPreferences for M1 dev, file on desktop).
*/
interface SecureStore {
/** ws(s)://host:port/ws */
var serverUrl: String
/** ANDROID_TOKEN presented in the hello frame. */
var token: String
/** Stable app-generated device id (persisted). */
val deviceId: String
/** Human-readable device name (e.g. "MIX 2S"). */
val deviceName: String
fun savePairing(url: String, token: String)
fun clear()
}
@@ -0,0 +1,281 @@
package iris.net
import iris.data.SecureStore
import iris.protocol.ChannelInfo
import iris.protocol.ErrorPayload
import iris.protocol.Frame
import iris.protocol.HelloAckPayload
import iris.protocol.IrisJson
import iris.protocol.ServerCaps
import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HELLO_ACK
import iris.protocol.TYPE_PONG
import iris.protocol.helloFrame
import iris.protocol.messageSendFrame
import iris.protocol.pingFrame
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.TimeMark
import kotlin.time.TimeSource
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit
import kotlin.random.Random
/**
* OkHttp WebSocket client for the hermes android gateway (docs/10 §10.3).
*
* - connect + hello (real auth leg), hello.ack
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect
* - heartbeat: app-level ping every 20s; reap after ~60s of silence
* - events: server frames (minus hello.ack) on [events]
* - request/response correlation by id (M2+ consumers)
*/
class GatewayClient(
private val scope: CoroutineScope,
private val store: SecureStore,
) {
sealed interface State {
data object Disconnected : State
data object Connecting : State
data class Connected(val caps: ServerCaps, val channels: List<ChannelInfo>) : State
data object Reconnecting : State
data class AuthFailed(val message: String) : State
}
private val _state = MutableStateFlow<State>(State.Disconnected)
val state: StateFlow<State> = _state.asStateFlow()
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
val events: SharedFlow<Frame> = _events.asSharedFlow()
private val client: OkHttpClient = OkHttpClient.Builder()
.pingInterval(20, TimeUnit.SECONDS)
.build()
private var connectJob: Job? = null
private var socket: WebSocket? = null
private var nextRequestId = 1
private var attempt = 0
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
// ── Lifecycle ─────────────────────────────────────────────────────────
/** Start (or resume) the connect loop. Idempotent. */
fun start() {
if (connectJob?.isActive == true) return
attempt = 0
connectJob = scope.launch { connectLoop() }
}
/** Stop the connect loop and close the socket. */
fun stop() {
connectJob?.cancel()
connectJob = null
socket?.close(1000, "client shutdown")
socket = null
_state.value = State.Disconnected
}
/** Re-pair: stop, then start fresh (used after saving new settings). */
fun restart() {
stop()
start()
}
private suspend fun connectLoop() {
while (currentCoroutineContext().isActive) {
val url = store.serverUrl.trim()
val token = store.token
if (url.isBlank() || token.isBlank()) {
_state.value = State.Disconnected
return
}
_state.value = if (attempt == 0) State.Connecting else State.Reconnecting
val dial = dial(url, token)
when (val result = dial.result) {
is DialResult.AuthFailed -> {
_state.value = State.AuthFailed(result.message)
dial.socket.close(1000, "auth failed")
return
}
DialResult.Connected -> {
attempt = 0
lastLiveness = TimeSource.Monotonic.markNow()
dial.closed.await()
if (!currentCoroutineContext().isActive) return
// socket dropped -> loop again (Reconnecting)
}
is DialResult.Failed -> {
attempt++
delay(backoffMs(attempt))
}
}
}
}
// ── Dial (one connect + hello) ────────────────────────────────────────
private sealed interface DialResult {
data object Connected : DialResult
data class AuthFailed(val message: String) : DialResult
data class Failed(val message: String) : DialResult
}
private data class Dial(
val result: DialResult,
val socket: WebSocket,
val closed: CompletableDeferred<Unit>,
)
private suspend fun dial(url: String, token: String): Dial {
val closed = CompletableDeferred<Unit>()
val helloAck = CompletableDeferred<HelloAckPayload>()
val authError = CompletableDeferred<String>()
val fail = CompletableDeferred<String>()
val request = Request.Builder().url(url).build()
val ws = client.newWebSocket(
request,
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(helloFrame(token, store.deviceId, store.deviceName).toWire())
}
override fun onMessage(webSocket: WebSocket, text: String) {
lastLiveness = TimeSource.Monotonic.markNow()
val frame = try {
IrisJson.instance.decodeFromString(Frame.serializer(), text)
} catch (_: Exception) {
return
}
when (frame.type) {
TYPE_HELLO_ACK -> {
val ack = frame.payloadAs<HelloAckPayload>()
if (ack != null) helloAck.complete(ack)
}
TYPE_ERROR -> {
val err = frame.payloadAs<ErrorPayload>()
authError.complete(err?.message ?: "auth failed")
}
TYPE_PONG -> Unit
else -> {
_events.tryEmit(frame)
frame.id?.let { pending[it]?.complete(frame) }
}
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
closed.complete(Unit)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
fail.complete(t.message ?: "connection failed")
closed.complete(Unit)
}
},
)
socket = ws
val winner = CompletableDeferred<DialResult>()
helloAck.invokeOnCompletion { e ->
if (e == null) {
val ack = helloAck.getCompleted()
_state.value = State.Connected(ack.serverCaps, ack.channels)
winner.complete(DialResult.Connected)
}
}
authError.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.AuthFailed(authError.getCompleted()))
}
fail.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
}
val result = withTimeoutOrNull(15_000) { winner.await() }
?: DialResult.Failed("timeout waiting for hello.ack")
return Dial(result, ws, closed)
}
// ── Outbound ──────────────────────────────────────────────────────────
/** Send a text message (fire-and-forget; the server echoes it back). */
fun sendMessage(chatId: String, text: String, threadId: String? = null) {
val ws = socket ?: return
val id = nextRequestId++
ws.send(messageSendFrame(id, chatId, text, threadId).toWire())
}
/** Send a ping (heartbeat). */
fun ping() {
socket?.send(pingFrame().toWire())
}
/** True when the socket has been silent for [timeoutMs] (heartbeat reap). */
fun isStale(timeoutMs: Long = 60_000): Boolean =
_state.value is State.Connected && lastLiveness.elapsedNow().inWholeMilliseconds > timeoutMs
fun reapStale() {
if (isStale()) {
socket?.close(1000, "heartbeat timeout")
}
}
// ── One-shot hello test (Connect screen) ──────────────────────────────
/**
* Real `hello` test: dial, wait for hello.ack (or auth error), close.
* Exercises the auth leg, not just TCP (docs/10 §10.8).
*/
suspend fun testHello(url: String, token: String): Result<Unit> {
val dial = dial(url, token)
return when (val result = dial.result) {
DialResult.Connected -> {
dial.socket.close(1000, "test complete")
Result.success(Unit)
}
is DialResult.AuthFailed -> Result.failure(IllegalStateException(result.message))
is DialResult.Failed -> Result.failure(IllegalStateException(result.message))
}
}
// ── Heartbeat job ─────────────────────────────────────────────────────
fun startHeartbeat() {
scope.launch {
while (isActive) {
delay(20_000)
if (_state.value is State.Connected) {
ping()
reapStale()
}
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────
private fun backoffMs(attempt: Int): Long {
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
val capped = minOf(base, 30_000L)
return capped + Random.nextLong(0, 500)
}
}
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this)
@@ -0,0 +1,238 @@
package iris.protocol
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* Wire protocol frames (mirror of gateway-plugin/protocol.py).
* See docs/04-wire-protocol.md. M1: hello/hello.ack, message, message.send,
* error, ping/pong, typing. M2: message.start/update/stop, tool.start/
* progress/end, commentary, reasoning (on message / message.stop).
*/
const val PROTOCOL_VERSION = 1
object IrisJson {
val instance: Json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
isLenient = true
}
}
// ── Frame type constants ────────────────────────────────────────────────
const val TYPE_HELLO = "hello"
const val TYPE_HELLO_ACK = "hello.ack"
const val TYPE_MESSAGE = "message"
const val TYPE_MESSAGE_SEND = "message.send"
const val TYPE_ERROR = "error"
const val TYPE_PING = "ping"
const val TYPE_PONG = "pong"
const val TYPE_TYPING = "typing"
// M2 — streaming / tools / commentary
const val TYPE_MESSAGE_START = "message.start"
const val TYPE_MESSAGE_UPDATE = "message.update"
const val TYPE_MESSAGE_STOP = "message.stop"
const val TYPE_TOOL_START = "tool.start"
const val TYPE_TOOL_PROGRESS = "tool.progress"
const val TYPE_TOOL_END = "tool.end"
const val TYPE_COMMENTARY = "commentary"
// ── Error codes ─────────────────────────────────────────────────────────
const val ERR_AUTH = "auth"
const val ERR_NOT_FOUND = "not_found"
const val ERR_UNSUPPORTED = "unsupported"
const val ERR_INTERNAL = "internal"
// ── Roles ───────────────────────────────────────────────────────────────
const val ROLE_USER = "user"
const val ROLE_ASSISTANT = "assistant"
// ── Envelope ────────────────────────────────────────────────────────────
@Serializable
data class Frame(
val v: Int = PROTOCOL_VERSION,
val id: Int? = null,
val type: String,
@SerialName("chat_id") val chatId: String? = null,
@SerialName("thread_id") val threadId: String? = null,
val payload: JsonElement = JsonObject(emptyMap()),
) {
/** Payload as a JSON object (the wire format); parse per-type with
* [IrisJson]. Unknown fields are ignored (forward-compat). */
inline fun <reified T : Any> payloadAs(): T? =
try {
IrisJson.instance.decodeFromString<T>(payload.toString())
} catch (_: Exception) {
null
}
}
// ── hello (app -> server) ───────────────────────────────────────────────
@Serializable
data class HelloPayload(
val token: String,
@SerialName("device_id") val deviceId: String,
@SerialName("device_name") val deviceName: String,
val caps: JsonElement = buildJsonObject { put("min_protocol", JsonPrimitive(1)) },
@SerialName("fcm_token") val fcmToken: String? = null,
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
)
// ── hello.ack (server -> app) ───────────────────────────────────────────
@Serializable
data class ServerCaps(
val streaming: Boolean = false,
val reasoning: Boolean = false,
val tools: Boolean = false,
val media: Boolean = false,
val search: Boolean = false,
val push: String = "fcm",
val pickers: Boolean = false,
)
@Serializable
data class ChannelInfo(
@SerialName("chat_id") val chatId: String,
val name: String,
val kind: String = "channel",
@SerialName("is_default") val isDefault: Boolean = false,
)
@Serializable
data class HelloAckPayload(
@SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(),
@SerialName("sync_cursor") val syncCursor: Long = 0,
val channels: List<ChannelInfo> = emptyList(),
)
// ── message (server -> app) ─────────────────────────────────────────────
@Serializable
data class MessagePayload(
@SerialName("message_id") val messageId: String,
val role: String,
val text: String,
val reasoning: String? = null,
@SerialName("reply_to") val replyTo: String? = null,
val model: String? = null,
val tokens: Int? = null,
val ts: Long? = null,
)
// ── M2: streaming frames (server -> app) ────────────────────────────────
@Serializable
data class MessageStartPayload(
@SerialName("message_id") val messageId: String,
val role: String = ROLE_ASSISTANT,
)
@Serializable
data class MessageUpdatePayload(
@SerialName("message_id") val messageId: String,
val text: String,
)
@Serializable
data class MessageStopPayload(
@SerialName("message_id") val messageId: String,
@SerialName("final_text") val finalText: String,
val reasoning: String? = null,
val model: String? = null,
val tokens: Int? = null,
val ts: Long? = null,
)
// ── M2: tool activity frames (server -> app) ────────────────────────────
@Serializable
data class ToolStartPayload(
val index: Int,
val name: String,
val preview: String? = null,
val args: JsonElement? = null,
)
@Serializable
data class ToolProgressPayload(
val index: Int,
val name: String,
val note: String? = null,
)
@Serializable
data class ToolEndPayload(
val index: Int,
val name: String,
val ok: Boolean = true,
val duration: Double? = null,
@SerialName("output_preview") val outputPreview: String? = null,
)
// ── M2: commentary frame (server -> app) ────────────────────────────────
@Serializable
data class CommentaryPayload(
@SerialName("message_id") val messageId: String,
val text: String,
)
// ── message.send (app -> server) ────────────────────────────────────────
@Serializable
data class MessageSendPayload(
val text: String,
@SerialName("reply_to") val replyTo: String? = null,
@SerialName("media_refs") val mediaRefs: List<String> = emptyList(),
)
// ── typing / error / ping ───────────────────────────────────────────────
@Serializable
data class TypingPayload(val on: Boolean)
@Serializable
data class ErrorPayload(val code: String, val message: String)
@Serializable
data class PingPayload(val ts: Long? = null)
// ── Frame builders ──────────────────────────────────────────────────────
fun helloFrame(token: String, deviceId: String, deviceName: String): Frame =
Frame(
type = TYPE_HELLO,
payload = IrisJson.instance.encodeToJsonElement(
HelloPayload.serializer(),
HelloPayload(token = token, deviceId = deviceId, deviceName = deviceName),
),
)
fun messageSendFrame(id: Int, chatId: String, text: String, threadId: String? = null): Frame =
Frame(
id = id,
type = TYPE_MESSAGE_SEND,
chatId = chatId,
threadId = threadId,
payload = IrisJson.instance.encodeToJsonElement(
MessageSendPayload.serializer(),
MessageSendPayload(text = text),
),
)
fun pingFrame(): Frame =
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
@@ -0,0 +1,122 @@
package iris.state
import iris.data.ChatStore
import iris.data.SecureStore
import iris.net.GatewayClient
import iris.protocol.TYPE_COMMENTARY
import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE
import iris.protocol.TYPE_TOOL_END
import iris.protocol.TYPE_TOOL_PROGRESS
import iris.protocol.TYPE_TOOL_START
import iris.protocol.TYPE_TYPING
import iris.protocol.TypingPayload
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* App-level controller (M2): owns the GatewayClient + ChatStore, routes
* server frames (message / streaming / tool / commentary / typing) into
* state, and exposes the actions the UI needs.
*/
class IrisController(
private val store: SecureStore,
) {
private val job = SupervisorJob()
private val scope = CoroutineScope(job + Dispatchers.Default)
val client = GatewayClient(scope, store)
val chat = ChatStore()
private val _typing = MutableStateFlow(false)
val typing: StateFlow<Boolean> = _typing.asStateFlow()
/** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely. */
private val _toolDetail = MutableStateFlow(ToolDetail.TRUNCATED)
val toolDetail: StateFlow<ToolDetail> = _toolDetail.asStateFlow()
fun cycleToolDetail() {
_toolDetail.value = when (_toolDetail.value) {
ToolDetail.EVERYTHING -> ToolDetail.TRUNCATED
ToolDetail.TRUNCATED -> ToolDetail.NOTHING
ToolDetail.NOTHING -> ToolDetail.EVERYTHING
}
}
/** Home channel id (from hello.ack; default until then). */
private val _homeChannel = MutableStateFlow("android:default")
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
init {
scope.launch {
client.events.collect { frame ->
when (frame.type) {
TYPE_MESSAGE,
TYPE_MESSAGE_START,
TYPE_MESSAGE_UPDATE,
TYPE_MESSAGE_STOP,
TYPE_TOOL_START,
TYPE_TOOL_PROGRESS,
TYPE_TOOL_END,
TYPE_COMMENTARY -> chat.onFrame(frame)
TYPE_TYPING -> {
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
}
else -> Unit
}
}
}
scope.launch {
client.state.collect { s ->
if (s is GatewayClient.State.Connected) {
val home = s.channels.firstOrNull { it.isDefault }?.chatId
if (home != null) _homeChannel.value = home
}
}
}
client.startHeartbeat()
client.start()
}
/** Optimistic send: show immediately, then hand to the gateway. */
fun send(text: String) {
val trimmed = text.trim()
if (trimmed.isEmpty()) return
chat.addPending(trimmed)
client.sendMessage(_homeChannel.value, trimmed)
}
/** Connect-screen action: real hello test, then save + (re)connect. */
suspend fun connect(url: String, token: String): Result<Unit> {
val test = client.testHello(url, token)
if (test.isFailure) return test
store.savePairing(url, token)
client.restart()
return Result.success(Unit)
}
fun forget() {
store.clear()
chat.clear()
client.restart()
}
fun dispose() {
client.stop()
job.cancel()
}
}
/** How much tool detail to show (Settings → "Tool detail"). */
enum class ToolDetail {
EVERYTHING, // name + full args (collapsible) + output preview
TRUNCATED, // one-liner "emoji name: preview", collapsible (default)
NOTHING, // suppress tool cards entirely
}
@@ -0,0 +1,385 @@
package iris.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.data.ChatItem
import iris.data.MessageItem
import iris.data.ToolItem
import iris.net.GatewayClient
import iris.protocol.ROLE_USER
import iris.state.IrisController
import iris.state.ToolDetail
/**
* Chat screen (M2): message list + composer. User bubbles right (accent),
* assistant bubbles left (surface). M2 adds live streaming bubbles, a
* collapsible reasoning block above the answer, structured tool cards
* (verbosity-controlled), and dimmed commentary beats.
*/
@Composable
fun ChatScreen(controller: IrisController) {
val state by controller.client.state.collectAsState()
val items by controller.chat.items.collectAsState()
val typing by controller.typing.collectAsState()
val toolDetail by controller.toolDetail.collectAsState()
val listState = rememberLazyListState()
var input by remember { mutableStateOf("") }
fun doSend() {
if (input.isBlank()) return
val text = input
input = ""
controller.send(text)
}
LaunchedEffect(items.size, typing) {
val last = items.size - 1
if (last >= 0) listState.animateScrollToItem(last)
}
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Iris", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(10.dp))
StatusChip(state)
Spacer(modifier = Modifier.weight(1f))
// M2: cycle tool-card verbosity (full Settings screen lands in M7).
TextButton(onClick = { controller.cycleToolDetail() }) {
Text("tools: ${toolDetail.label}", fontSize = 11.sp)
}
}
// Messages
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (items.isEmpty() && !typing) {
item(key = "empty") {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
"Say hello to your agent.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
items(items, key = { it.id }) { item ->
when (item) {
is MessageItem -> MessageBubble(item)
is ToolItem -> if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail)
}
}
if (typing) {
item(key = "typing") {
Text(
"typing…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
// Composer
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.Bottom,
) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp, max = 160.dp),
placeholder = { Text("Message") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { doSend() }),
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { doSend() },
enabled = input.isNotBlank(),
) {
Text("Send")
}
}
}
}
@Composable
private fun StatusChip(state: GatewayClient.State) {
val (label, color) = when (state) {
GatewayClient.State.Disconnected -> "offline" to Color(0xFF9E9E9E)
GatewayClient.State.Connecting -> "connecting…" to Color(0xFFFFC107)
GatewayClient.State.Reconnecting -> "reconnecting…" to Color(0xFFFFC107)
is GatewayClient.State.Connected -> "connected" to Color(0xFF4CAF50)
is GatewayClient.State.AuthFailed -> "auth failed" to Color(0xFFF44336)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(color.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(label, fontSize = 11.sp, color = color)
}
}
@Composable
private fun MessageBubble(msg: MessageItem) {
val isUser = msg.role == ROLE_USER
val isCommentary = msg.isCommentary
val bubbleColor = when {
isUser -> Color(0xFF4F7CFF)
isCommentary -> Color(0xFF23262F)
else -> Color(0xFF2A2E3B)
}
val textColor = when {
isUser -> Color.White
isCommentary -> Color(0xFFE8EAF0).copy(alpha = 0.55f)
else -> Color(0xFFE8EAF0)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(14.dp))
.background(bubbleColor)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
// Reasoning block above the answer (assistant, non-commentary).
if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) {
ReasoningBlock(msg.reasoning!!)
Spacer(modifier = Modifier.height(6.dp))
}
if (msg.text.isNotBlank() || msg.streaming) {
Text(
msg.text + if (msg.streaming) " ▉" else "",
color = textColor,
fontSize = if (isCommentary) 13.sp else 15.sp,
)
}
if (msg.pending) {
Text("sending…", color = textColor.copy(alpha = 0.6f), fontSize = 10.sp)
}
// Model / token footer (final assistant answers only).
if (!isUser && !isCommentary && !msg.streaming &&
(msg.model != null || msg.tokens != null)
) {
Spacer(modifier = Modifier.height(4.dp))
Text(
buildString {
msg.model?.let { append(it) }
if (msg.model != null && msg.tokens != null) append(" · ")
msg.tokens?.let { append("${it} tok") }
},
color = textColor.copy(alpha = 0.4f),
fontSize = 10.sp,
)
}
}
}
}
/**
* Collapsible reasoning panel (M2): header "💭 Reasoning", monospace body,
* copy button. Collapsed by default when long; tap to toggle.
*/
@Composable
private fun ReasoningBlock(reasoning: String) {
val clipboard = LocalClipboardManager.current
var expanded by remember {
mutableStateOf(reasoning.length <= 240)
}
var copied by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF1E212B))
.padding(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"💭 Reasoning",
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFFB9C0D0),
modifier = Modifier
.weight(1f)
.clickable { expanded = !expanded },
)
TextButton(onClick = {
clipboard.setText(AnnotatedString(reasoning))
copied = true
}) {
Text(if (copied) "copied" else "copy", fontSize = 11.sp)
}
Text(
if (expanded) "▾" else "▸",
fontSize = 12.sp,
color = Color(0xFFB9C0D0),
modifier = Modifier.clickable { expanded = !expanded },
)
}
if (expanded) {
Text(
reasoning,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = Color(0xFF9AA3B5),
)
}
}
}
/**
* Structured tool-activity card (M2). Spinner while [ToolItem.done] is false,
* ✓/✗ + duration on completion. Verbosity controlled by [detail].
*/
@Composable
private fun ToolCard(tool: ToolItem, detail: ToolDetail) {
var expanded by remember { mutableStateOf(false) }
val status = when {
!tool.done -> null
tool.ok -> "✓"
else -> "✗"
}
val statusColor = when {
tool.ok -> Color(0xFF4CAF50)
else -> Color(0xFFF44336)
}
val preview = tool.preview ?: tool.note
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF20242E))
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!tool.done) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
} else {
Text(status!!, color = statusColor, fontSize = 14.sp)
Spacer(modifier = Modifier.width(6.dp))
}
Text(
"🔧 ${tool.name}",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFFD7DBE5),
modifier = Modifier
.weight(1f)
.clickable { expanded = !expanded },
)
if (tool.duration != null) {
Text(
"${String.format("%.1f", tool.duration)}s",
fontSize = 11.sp,
color = Color(0xFF8A93A6),
)
}
}
// One-liner preview (Truncated default / Everything).
if (preview != null && detail != ToolDetail.NOTHING) {
Text(
preview,
fontSize = 12.sp,
color = Color(0xFF9AA3B5),
maxLines = if (expanded) Int.MAX_VALUE else 1,
)
}
// Full args + output preview (Everything, expanded).
if (detail == ToolDetail.EVERYTHING && expanded) {
tool.args?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
it.toString(),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color(0xFF8A93A6),
)
}
tool.outputPreview?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
it,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color(0xFF8A93A6),
)
}
}
}
}
private val ToolDetail.label: String
get() = when (this) {
ToolDetail.EVERYTHING -> "everything"
ToolDetail.TRUNCATED -> "truncated"
ToolDetail.NOTHING -> "nothing"
}
@@ -0,0 +1,124 @@
package iris.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import iris.state.IrisController
import kotlinx.coroutines.launch
/**
* Onboarding / pairing screen (docs/10 §10.8). "Test & Connect" performs a
* real `hello` (the auth leg), not just a TCP probe.
*/
@Composable
fun ConnectScreen(
controller: IrisController,
prefillUrl: String = "",
prefillToken: String = "",
initialError: String? = null,
) {
val scope = rememberCoroutineScope()
var url by remember { mutableStateOf(prefillUrl.ifBlank { "ws://" }) }
var token by remember { mutableStateOf(prefillToken) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(initialError) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(48.dp))
Text("Iris × Hermes", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Pair this device with your hermes gateway",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("Server URL") },
placeholder = { Text("ws://192.168.1.10:8790/ws") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = token,
onValueChange = { token = it },
label = { Text("Pairing token") },
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
if (busy) return@Button
busy = true
error = null
scope.launch {
val result = controller.connect(url.trim(), token.trim())
busy = false
if (result.isFailure) {
error = result.exceptionOrNull()?.message ?: "connection failed"
}
}
},
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (busy) "Testing connection…" else "Test & Connect")
}
if (error != null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" +
"hermes gateway setup on the gateway host.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,75 @@
package iris.platform
import iris.data.SecureStore
import iris.protocol.IrisJson
import java.io.File
import java.util.UUID
import kotlinx.serialization.Serializable
/**
* Desktop pairing storage: a small JSON file under the user home
* (`~/.iris/pairing.json`). M1 dev storage; a keyring backend is a
* later-milestone concern.
*/
@Serializable
private data class PairingData(
val serverUrl: String = "",
val token: String = "",
val deviceId: String = "",
)
class DesktopSecureStore : SecureStore {
private val file: File = File(System.getProperty("user.home"), ".iris/pairing.json")
private fun load(): PairingData =
if (file.exists()) {
try {
IrisJson.instance.decodeFromString(PairingData.serializer(), file.readText())
} catch (_: Exception) {
PairingData()
}
} else {
PairingData()
}
private fun save(data: PairingData) {
file.parentFile?.mkdirs()
file.writeText(IrisJson.instance.encodeToString(PairingData.serializer(), data))
}
override var serverUrl: String
get() = load().serverUrl
set(value) {
val d = load()
save(d.copy(serverUrl = value.trim()))
}
override var token: String
get() = load().token
set(value) {
val d = load()
save(d.copy(token = value.trim()))
}
override val deviceId: String
get() {
val d = load()
if (d.deviceId.isNotBlank()) return d.deviceId
val id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
save(d.copy(deviceId = id))
return id
}
override val deviceName: String
get() = "Desktop (${System.getProperty("os.name")})"
override fun savePairing(url: String, token: String) {
val d = load()
save(d.copy(serverUrl = url.trim(), token = token.trim()))
}
override fun clear() {
val d = load()
save(d.copy(serverUrl = "", token = ""))
}
}
+27 -17
View File
@@ -7,15 +7,15 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
## M0 — Toolchain & scaffolding ## M0 — Toolchain & scaffolding
**Goal:** everything builds; the plugin is discoverable; the repo is safe. **Goal:** everything builds; the plugin is discoverable; the repo is safe.
- [ ] Install JDK 17, Android SDK, set `ANDROID_HOME` (`12-toolchain.md`). - [X] Install JDK 17, Android SDK, set `ANDROID_HOME` (`12-toolchain.md`).
- [ ] `cd hermes-agent && uv sync` (hermes venv works). - [X] `cd hermes-agent && uv sync` (hermes venv works).
- [ ] Create monorepo scaffold (`02-monorepo.md`): `gateway-plugin/`, `app/` - [X] Create monorepo scaffold (`02-monorepo.md`): `gateway-plugin/`, `app/`
(CMP: `shared`, `androidApp`, `desktopApp`), root `.gitignore` (CMP: `shared`, `androidApp`, `desktopApp`), root `.gitignore`
(**excludes `hermes-agent/`**), root `README.md`. (**excludes `hermes-agent/`**), root `README.md`.
- [ ] `git init` + a pre-commit/CI guard that fails if `hermes-agent/` is staged. - [X] `git init` + a pre-commit/CI guard that fails if `hermes-agent/` is staged.
- [ ] CMP project builds empty: `./gradlew :androidApp:assembleDebug`, - [X] CMP project builds empty: `./gradlew :androidApp:assembleDebug`,
`./gradlew :desktopApp:run` (blank window). `./gradlew :desktopApp:run` (blank window).
- [ ] Plugin skeleton: `plugin.yaml` + `adapter.py` with `register(ctx)` + a - [X] Plugin skeleton: `plugin.yaml` + `adapter.py` with `register(ctx)` + a
no-op `AndroidAdapter` → `hermes gateway status` lists **android**. no-op `AndroidAdapter` → `hermes gateway status` lists **android**.
- **Demo:** `hermes gateway status` shows `android`; `./gradlew - **Demo:** `hermes gateway status` shows `android`; `./gradlew
:androidApp:installDebug` installs a blank app on the MIX 2S. :androidApp:installDebug` installs a blank app on the MIX 2S.
@@ -25,31 +25,41 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
## M1 — Gateway core loop (text round-trip) ## M1 — Gateway core loop (text round-trip)
**Goal:** pair + send a text message + get a (non-streaming) reply. **Goal:** pair + send a text message + get a (non-streaming) reply.
- [ ] WS server (`ws_server.py`): bind, `hello` auth (constant-time), - [X] WS server (`ws_server.py`): bind, `hello` auth (constant-time),
`hello.ack`, heartbeat, connection registry. `hello.ack`, heartbeat, connection registry.
- [ ] `AndroidAdapter.send()` → `message` frame; inbound `message.send` → - [X] `AndroidAdapter.send()` → `message` frame; inbound `message.send` →
`MessageEvent` → `handle_message`. `MessageEvent` → `handle_message`.
- [ ] Pairing store + `ANDROID_TOKEN`; QR payload in `interactive_setup`. - [X] Pairing store + `ANDROID_TOKEN`; QR payload in `interactive_setup`.
- [ ] App: Connect screen (URL+token, real `hello` test), `GatewayClient` - [X] App: Connect screen (URL+token, real `hello` test), `GatewayClient`
(connect + reconnect), ChatScreen sends + renders `message`. (connect + reconnect), ChatScreen sends + renders `message`.
- [ ] `ws_probe.py` harness drives a real turn. - [X] `ws_probe.py` harness drives a real turn.
- **Demo (on-device):** pair the phone, send "hello", see the agent's reply. - **Demo (on-device):** pair the phone, send "hello", see the agent's reply.
- **Accept:** text round-trip works on-device; wrong token is rejected; - **Accept:** text round-trip works on-device; wrong token is rejected;
reconnect after gateway restart re-pairs. reconnect after gateway restart re-pairs.
## M2 — Streaming + reasoning + tools + commentary ## M2 — Streaming + reasoning + tools + commentary
**Goal:** the "agent transparency" features. **Goal:** the "agent transparency" features.
- [ ] Map consumer `send`/`edit_message` → `message.start/update/stop`. - [X] Map consumer `send`/`edit_message` → `message.start/update/stop`.
- [ ] Reasoning: set `show_reasoning` for android; adapter splits prefix → - [X] Reasoning: set `show_reasoning` for android; adapter splits prefix →
`reasoning` field. **Verify format with `ws_probe.py`.** `reasoning` field. **Verify format with `ws_probe.py`.** (The model
- [ ] Tool events: classify tool-progress `send()`s → structured returns a separate `reasoning_content` field. In the *streaming* case the
gateway drops it — the stream consumer only forwards `content` and the
final send is suppressed — so the adapter captures it via the
`on_stream_delta` plugin hook (`kind="reasoning"`, gated by
`plugins.stream_reasoning_deltas: true`) and attaches it to
`message.stop`. Non-streaming already carried it via the prefix split.)
- [X] Tool events: classify tool-progress `send()`s → structured
`tool.start/progress/end` (turn-state machine). **Verify with probe.** `tool.start/progress/end` (turn-state machine). **Verify with probe.**
- [ ] Commentary → `commentary` frames. Typing → `typing`. - [X] Commentary → `commentary` frames. Typing → `typing`.
- [ ] App: live bubble (coalesced updates), `ReasoningBlock` (collapse + copy), - [X] App: live bubble (coalesced updates), `ReasoningBlock` (collapse + copy),
`ToolCard` with **Everything/Truncated/Nothing** setting, dimmed `ToolCard` with **Everything/Truncated/Nothing** setting, dimmed
`commentary` bubble. `commentary` bubble.
- **Demo (on-device):** a multi-step prompt streams, shows reasoning above the - **Demo (on-device):** a multi-step prompt streams, shows reasoning above the
answer, tool cards (toggle verbosity), and an intermediate beat. answer, tool cards (toggle verbosity), and an intermediate beat.
(Verified on the MIX 2S: streaming, tool cards + verbosity toggle, and the
reasoning panel rendering above the answer. The commentary beat is
model/agent-dependent and did not fire with the current model, but the
rendering + frame handling are in place.)
- **Accept:** all four render correctly; tool verbosity setting changes - **Accept:** all four render correctly; tool verbosity setting changes
rendering; reasoning copy button works; frame shapes match `04-wire-protocol`. rendering; reasoning copy button works; frame shapes match `04-wire-protocol`.
+759 -51
View File
@@ -9,11 +9,17 @@ with a pairing token and talks to the agent over a single WS transport
Zero new Python dependencies: ``websockets`` and ``httpx`` are hermes core Zero new Python dependencies: ``websockets`` and ``httpx`` are hermes core
deps. Zero hermes-core changes. deps. Zero hermes-core changes.
Milestone M0: this is a *skeleton* adapter. It registers the ``android`` Milestone M1: the gateway core loop (text round-trip). The WS server binds
platform, resolves its configuration, and implements the abstract adapter and authenticates devices (``hello`` with constant-time token check), the
contract as no-ops so that ``hermes gateway status`` lists ``android``. The adapter emits ``message`` frames from ``send()`` and turns inbound
WebSocket server, pairing, streaming, media, outbox, push, and search are ``message.send`` frames into ``MessageEvent``s for ``handle_message()``.
wired in later milestones (see ``docs/14-milestones.md``).
Milestone M2: agent transparency. ``send()``/``edit_message()`` are mapped to
``message.start``/``message.update``/``message.stop`` (streaming), tool
progress is classified into structured ``tool.start``/``tool.end`` frames,
interim commentary becomes ``commentary`` frames, and the code-style
reasoning prefix is split into a ``reasoning`` field. Media, outbox, push,
and search land in later milestones (see ``docs/14-milestones.md``).
Configuration in config.yaml:: Configuration in config.yaml::
@@ -34,11 +40,15 @@ Or via environment variables (overrides config.yaml; secrets live in .env):
ANDROID_PUSH_BACKEND, ANDROID_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ... ANDROID_PUSH_BACKEND, ANDROID_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
""" """
import asyncio
import logging import logging
import os import os
import re
import threading
import time import time
import uuid import uuid
from typing import Any, Dict, List, Optional from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret from agent.secret_scope import get_secret as _scoped_get_secret
@@ -79,6 +89,77 @@ from gateway.platforms.base import ( # noqa: E402
MessageType, MessageType,
) )
from gateway.config import Platform # noqa: E402 from gateway.config import Platform # noqa: E402
from hermes_constants import get_hermes_home # noqa: E402
from . import protocol # noqa: E402
from .pairing import ( # noqa: E402
DeviceRegistry,
generate_token,
pairing_url,
qr_payload,
)
from .ws_server import WsServer # noqa: E402
# ---------------------------------------------------------------------------
# M2 — reasoning capture (streaming)
#
# The gateway streams only ``content`` to the platform and suppresses the
# final send (which would carry the prepended reasoning), so the model's
# separate ``reasoning_content`` is otherwise lost in the streaming case.
# hermes exposes a plugin ``on_stream_delta`` hook that fires reasoning
# deltas with ``kind="reasoning"`` (gated by ``plugins.stream_reasoning_deltas``).
# We accumulate those deltas here and attach the result to the turn's
# ``message.stop`` frame. Single-chat for now (android:default), so a
# module-level buffer suffices; it is reset at each turn start.
# ---------------------------------------------------------------------------
_reasoning_parts: List[str] = []
_reasoning_lock = threading.Lock()
# Barrier: set by the hook worker once it has processed the first content
# delta (kind="text"). The worker drains a FIFO queue and reasoning deltas are
# enqueued before content deltas, so at that point every reasoning delta has
# already been appended -- a reliable "reasoning flushed" signal that avoids
# racing message.stop against the async hook thread.
_reasoning_flushed = threading.Event()
def _on_stream_delta(**kwargs: Any) -> None:
"""Plugin hook: capture reasoning deltas (kind="reasoning")."""
kind = kwargs.get("kind")
if kind == "reasoning":
delta = kwargs.get("delta") or ""
if delta:
with _reasoning_lock:
_reasoning_parts.append(delta)
elif kind == "text":
_reasoning_flushed.set()
async def _wait_for_reasoning_flushed(timeout: float = 0.3) -> None:
"""Wait (without blocking the event loop) until the hook worker has
processed all reasoning deltas, or *timeout* seconds elapse."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
if _reasoning_flushed.is_set():
return
await asyncio.sleep(0.01)
def _take_reasoning() -> str:
"""Drain and return the accumulated reasoning (empty string if none)."""
with _reasoning_lock:
parts = _reasoning_parts[:]
_reasoning_parts.clear()
_reasoning_flushed.clear()
return "".join(parts).strip()
def _reset_reasoning() -> None:
with _reasoning_lock:
_reasoning_parts.clear()
_reasoning_flushed.clear()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -88,6 +169,7 @@ from gateway.config import Platform # noqa: E402
DEFAULT_HOST = "127.0.0.1" DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8790 DEFAULT_PORT = 8790
DEFAULT_HOME_CHANNEL = "android:default" DEFAULT_HOME_CHANNEL = "android:default"
DEFAULT_HOME_CHANNEL_NAME = "Default"
DEFAULT_PUSH_BACKEND = "fcm" DEFAULT_PUSH_BACKEND = "fcm"
DEFAULT_OUTBOX_RETENTION_HOURS = 72 DEFAULT_OUTBOX_RETENTION_HOURS = 72
DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
@@ -97,6 +179,211 @@ def _truthy(value: Optional[str]) -> bool:
return (value or "").strip().lower() in {"1", "true", "yes", "on"} return (value or "").strip().lower() in {"1", "true", "yes", "on"}
# ---------------------------------------------------------------------------
# M2 — turn state + outbound classification
#
# The main gateway delivers through the legacy callback path: the stream
# consumer calls ``send()`` (first bubble of a segment) and ``edit_message()``
# (updates), tool progress flows through ``send()``/``edit_message()`` of an
# accumulated line buffer, and interim commentary arrives as a plain
# ``send()``. We classify each outbound call into a structured frame using a
# per-chat turn state machine + content markers:
#
# * ``metadata["expect_edits"] is True`` -> streaming segment start
# * ``metadata["notify"] is True`` -> final message (or fallback final)
# * tool-progress line format -> tool.start / tool.end
# * anything else -> commentary
#
# Verified empirically against the live gateway with ``tests/ws_probe.py``.
# ---------------------------------------------------------------------------
# Streaming cursor the gateway appends to in-progress edits (" ▉"). Stripped
# before we forward text to the app (the app renders its own live indicator).
_STREAMING_CURSOR = " ▉"
# Code-style reasoning prefix (gateway/run.py, reasoning_style="code"):
# "💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>"
_REASONING_PREFIX = "💭 **Reasoning:**\n```\n"
_REASONING_CLOSE = "\n```\n\n"
# A gateway tool-progress line begins with a (non-ASCII) tool emoji.
_TOOL_LINE_RE = re.compile(r"^(\S+)\s+(.+)$")
_TOOL_NAME_PREVIEW_RE = re.compile(r'^(\S+):\s*"(.*)"\s*$')
_TOOL_NAME_BARE_RE = re.compile(r"^(\S+)\.\.\.\s*$")
_TOOL_NAME_ARGS_RE = re.compile(r"^(\S+)\(([^)]*)\)\s*$")
# Terminal code block: "💻 terminal\n```\n<cmd>\n```"
_TOOL_CODEBLOCK_HEAD_RE = re.compile(r"^(\S+)\s+(\S+)\s*$")
# Reverse map of the gateway's friendly tool verbs (agent/display.py
# _TOOL_VERBS) so a verb-form line ("🔍 Searching the web for …") can be
# recovered to a structured (tool_name, preview). Longest-first matching is
# done at parse time. Verbs shared by several tools map to the most common.
_VERB_TO_TOOL: Dict[str, str] = {
"Searching the web": "web_search",
"Searching files": "search_files",
"Searching past sessions": "session_search",
"Running code": "execute_code",
"Running": "terminal",
"Reading skill": "skill_view",
"Reading": "read_file",
"Writing": "write_file",
"Editing": "patch",
"Browsing": "browser_navigate",
"Clicking": "browser_click",
"Typing": "browser_type",
"Generating image": "image_generate",
"Generating video": "video_generate",
"Generating speech": "text_to_speech",
"Looking at the image": "vision_analyze",
"Listing skills": "skills_list",
"Updating skill": "skill_manage",
"Updating memory": "memory",
"Updating tasks": "todo",
"Delegating": "delegate_task",
"Scheduling": "cronjob",
"Asking": "clarify",
}
# Verbs that take a " for " connector before the preview.
_VERB_FOR_CONNECTOR = {"web_search", "search_files"}
def _mint_message_id() -> str:
return f"m_{uuid.uuid4().hex[:16]}"
def _thread_id_from_metadata(metadata: Optional[Dict[str, Any]]) -> Optional[str]:
if not metadata:
return None
tid = metadata.get("thread_id")
if isinstance(tid, str) and tid:
return tid
return None
def _strip_streaming_cursor(text: str) -> str:
if text and text.endswith(_STREAMING_CURSOR):
return text[: -len(_STREAMING_CURSOR)]
return text
def _split_reasoning(text: str) -> Tuple[Optional[str], str]:
"""Split a code-style reasoning prefix off the front of *text*.
Returns ``(reasoning, body)``; ``reasoning`` is ``None`` when no prefix is
present (reasoning off / no reasoning / non-code style). Best-effort parse
of a stable, gateway-owned format: on any mismatch the fallback is
``(None, full text)`` so the answer still renders.
"""
if not text or not text.startswith(_REASONING_PREFIX):
return None, text
close_idx = text.find(_REASONING_CLOSE, len(_REASONING_PREFIX))
if close_idx == -1:
return None, text
reasoning = text[len(_REASONING_PREFIX):close_idx]
body = text[close_idx + len(_REASONING_CLOSE):]
return reasoning, body
def _parse_tool_line(line: str) -> Optional[Tuple[str, Optional[str]]]:
"""Parse a single gateway tool-progress line into ``(name, preview)``.
Returns ``None`` when the line is not a tool line. The gateway formats
tool lines as ``<emoji> <name>: "<preview>"``, ``<emoji> <name>...``,
``<emoji> <name>(keys)``, or a friendly verb phrase (``<emoji> <verb> …``).
The verb form is lossy (no tool name), so we surface the verb as the name.
"""
line = line.strip()
if not line:
return None
m = _TOOL_LINE_RE.match(line)
if not m:
return None
emoji, rest = m.group(1), m.group(2)
if emoji.isascii():
return None # a tool line always leads with a non-ASCII emoji
mp = _TOOL_NAME_PREVIEW_RE.match(rest)
if mp:
return mp.group(1), mp.group(2)
mb = _TOOL_NAME_BARE_RE.match(rest)
if mb:
return mb.group(1), None
ma = _TOOL_NAME_ARGS_RE.match(rest)
if ma:
return ma.group(1), None
# Friendly verb phrase: reverse-map to (tool_name, preview).
verb_parsed = _parse_verb_phrase(rest)
if verb_parsed is not None:
return verb_parsed
# Unrecognised: use the phrase as the label.
return rest, None
def _parse_verb_phrase(phrase: str) -> Optional[Tuple[str, Optional[str]]]:
"""Reverse-map a friendly verb phrase to ``(tool_name, preview)``.
Matches the longest verb first so "Running code" wins over "Running".
Returns ``None`` when no known verb leads the phrase.
"""
for verb in sorted(_VERB_TO_TOOL, key=len, reverse=True):
tool = _VERB_TO_TOOL[verb]
if phrase == verb:
return tool, None
if tool in _VERB_FOR_CONNECTOR and phrase.startswith(verb + " for "):
return tool, phrase[len(verb) + len(" for "):].strip() or None
if phrase.startswith(verb + " "):
return tool, phrase[len(verb) + 1:].strip() or None
return None
def _extract_code_block(content: str) -> Optional[str]:
"""Return the first fenced code block's body in *content*, else ``None``.
Used to recover the terminal command from a tool-progress code block
(``<emoji> terminal`` head line + fenced command).
"""
m = re.search(r"```[^\n]*\n(.*?)\n```", content, re.DOTALL)
if m:
return m.group(1).strip() or None
return None
def _is_tool_progress(content: str) -> bool:
"""Heuristic: does *content* look like gateway tool-progress line(s)?
Tool progress is delivered as one or more lines, each led by a tool emoji
(or a terminal code block). Commentary is free-form prose. We classify on
the first non-empty line; subsequent lines of the same bubble are tracked
by message id, not re-classified.
"""
if not content:
return False
lines = [ln for ln in content.splitlines() if ln.strip()]
if not lines:
return False
first = lines[0].strip()
# Terminal code block: "<emoji> terminal" then a fenced command.
if len(lines) > 1 and lines[1].strip().startswith("```"):
return _TOOL_CODEBLOCK_HEAD_RE.match(first) is not None
return _parse_tool_line(first) is not None
@dataclass
class _TurnState:
"""Per-chat turn state for outbound frame classification (M2)."""
active: bool = False
# message_id of the currently streaming segment (message.start open).
stream_id: Optional[str] = None
# message_id of the current tool-progress bubble (editable line buffer).
tool_msg_id: Optional[str] = None
# Monotonic per-turn tool counter (start -> end correlation).
tool_index: int = 0
# Index of the most recently started tool (awaiting tool.end).
open_tool_index: Optional[int] = None
# Tool lines already emitted as tool.start (dedup across edits).
seen_tool_lines: set = field(default_factory=set)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Passive / config probes (called from status displays -- no side effects) # Passive / config probes (called from status displays -- no side effects)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -147,18 +434,25 @@ def _env_enablement() -> Optional[dict]:
if not token: if not token:
return None return None
seed: Dict[str, Any] = { # Seed ONLY explicitly-set env vars: the core commits this seed on top of
"host": os.getenv("ANDROID_WS_HOST", "").strip() or DEFAULT_HOST, # config.yaml (``extra.update(seed)``), so default values here would
"port": _parse_port(os.getenv("ANDROID_WS_PORT", "")), # clobber user YAML. Unset keys fall through to config.yaml / adapter
"push_backend": ( # defaults.
os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() seed: Dict[str, Any] = {}
or DEFAULT_PUSH_BACKEND host = os.getenv("ANDROID_WS_HOST", "").strip()
), if host:
} seed["host"] = host
home = os.getenv("ANDROID_HOME_CHANNEL", "").strip() or DEFAULT_HOME_CHANNEL port_raw = os.getenv("ANDROID_WS_PORT", "").strip()
if port_raw:
seed["port"] = _parse_port(port_raw)
push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
if push:
seed["push_backend"] = push
home = os.getenv("ANDROID_HOME_CHANNEL", "").strip()
if home:
seed["home_channel"] = { seed["home_channel"] = {
"chat_id": home, "chat_id": home,
"name": os.getenv("ANDROID_HOME_CHANNEL_NAME", "").strip() or "Default", "name": os.getenv("ANDROID_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME,
} }
return seed return seed
@@ -215,7 +509,7 @@ async def _standalone_send(
The outbox is served by the *running* gateway, so standalone delivery The outbox is served by the *running* gateway, so standalone delivery
while the gateway process is fully down is best-effort only (see while the gateway process is fully down is best-effort only (see
``docs/00-overview.md`` "Out of scope"). For M0 this is a stub that ``docs/00-overview.md`` "Out of scope"). For M1 this is a stub that
reports the gateway is required; the real implementation lands with the reports the gateway is required; the real implementation lands with the
outbox (M3/M5). outbox (M3/M5).
""" """
@@ -228,14 +522,14 @@ async def _standalone_send(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Interactive setup (hermes gateway setup flow) -- full version in M1 # Interactive setup (hermes gateway setup flow)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def interactive_setup() -> None: def interactive_setup() -> None:
"""Prompt for the pairing token / host / port / push backend. """Prompt for the pairing token / host / port / push backend.
M0: minimal. M1 adds token generation, QR payload, and a live ``hello`` M1: token generation, host/port/push prompts, and the pairing QR payload
connectivity test. (``iris://pair?...``) + app URL printed for the Connect screen.
""" """
try: try:
from hermes_cli.config import ( from hermes_cli.config import (
@@ -253,7 +547,7 @@ def interactive_setup() -> None:
print_info("📱 Android / Desktop (Iris x Hermes)") print_info("📱 Android / Desktop (Iris x Hermes)")
token = get_env_value("ANDROID_TOKEN") or "" token = get_env_value("ANDROID_TOKEN") or ""
if not token: if not token:
generated = uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars generated = generate_token()
save_env_value("ANDROID_TOKEN", generated) save_env_value("ANDROID_TOKEN", generated)
print_success(f"Generated pairing token: {generated}") print_success(f"Generated pairing token: {generated}")
print_warning("Keep this secret -- the app presents it on connect.") print_warning("Keep this secret -- the app presents it on connect.")
@@ -267,6 +561,19 @@ def interactive_setup() -> None:
backend = prompt("Push backend (fcm/ntfy)", default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND) backend = prompt("Push backend (fcm/ntfy)", default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND)
save_env_value("ANDROID_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower()) save_env_value("ANDROID_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower())
# Pairing payload for the app's Connect screen (QR / manual entry).
try:
from hermes_cli.config import print_code
url = pairing_url(host or DEFAULT_HOST, _parse_port(port))
payload = qr_payload(host or DEFAULT_HOST, _parse_port(port), token)
print_info("Pair your device (scan with the app or enter on the Connect screen):")
print_code(payload)
print_info(f"Server URL: {url}")
except Exception:
url = pairing_url(host or DEFAULT_HOST, _parse_port(port))
print_info(f"Pairing URL: {qr_payload(host or DEFAULT_HOST, _parse_port(port), token)}")
print_info(f"Server URL: {url}")
print_success("Android configuration saved to ~/.hermes/.env") print_success("Android configuration saved to ~/.hermes/.env")
print_info("Restart the gateway for changes to take effect: hermes gateway restart") print_info("Restart the gateway for changes to take effect: hermes gateway restart")
@@ -278,10 +585,10 @@ def interactive_setup() -> None:
class AndroidAdapter(BasePlatformAdapter): class AndroidAdapter(BasePlatformAdapter):
"""WebSocket-backed adapter for the native Iris Android / Desktop app. """WebSocket-backed adapter for the native Iris Android / Desktop app.
M0: skeleton. Implements the abstract adapter contract as no-ops and M1: the WS server (``ws_server.WsServer``) authenticates devices with the
resolves configuration. The WebSocket server, connection registry, pairing token, the connection registry tracks live sockets, ``send()``
pairing, streaming, media, outbox, push, and search are added in later emits ``message`` frames, and inbound ``message.send`` frames become
milestones. ``MessageEvent``s for ``handle_message()``.
""" """
def __init__(self, config, **kwargs): def __init__(self, config, **kwargs):
@@ -294,7 +601,6 @@ class AndroidAdapter(BasePlatformAdapter):
self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST) self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
self.port = _parse_port(os.getenv("ANDROID_WS_PORT", "") or str(extra.get("port", DEFAULT_PORT))) self.port = _parse_port(os.getenv("ANDROID_WS_PORT", "") or str(extra.get("port", DEFAULT_PORT)))
self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "") self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
self.home_channel = extra.get("home_channel", DEFAULT_HOME_CHANNEL)
self.push_backend = ( self.push_backend = (
os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
or extra.get("push_backend", DEFAULT_PUSH_BACKEND) or extra.get("push_backend", DEFAULT_PUSH_BACKEND)
@@ -306,6 +612,25 @@ class AndroidAdapter(BasePlatformAdapter):
extra.get("max_upload_bytes", DEFAULT_MAX_UPLOAD_BYTES) extra.get("max_upload_bytes", DEFAULT_MAX_UPLOAD_BYTES)
) )
# Home channel: the core hook turns the env-seeded ``home_channel``
# dict into a HomeChannel dataclass on the config; config.yaml may
# also put it in extra (dict or bare string).
home = getattr(config, "home_channel", None)
if home is not None and getattr(home, "chat_id", None):
self.home_channel = str(home.chat_id)
self.home_channel_name = str(getattr(home, "name", "") or DEFAULT_HOME_CHANNEL_NAME)
else:
hc = extra.get("home_channel")
if isinstance(hc, dict) and hc.get("chat_id"):
self.home_channel = str(hc["chat_id"])
self.home_channel_name = str(hc.get("name") or DEFAULT_HOME_CHANNEL_NAME)
elif isinstance(hc, str) and hc.strip():
self.home_channel = hc.strip()
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
else:
self.home_channel = DEFAULT_HOME_CHANNEL
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
# TLS (optional) # TLS (optional)
self.ws_cert = _get_scoped_secret("ANDROID_WS_CERT") or extra.get("ws_cert", "") self.ws_cert = _get_scoped_secret("ANDROID_WS_CERT") or extra.get("ws_cert", "")
self.ws_key = _get_scoped_secret("ANDROID_WS_KEY") or extra.get("ws_key", "") self.ws_key = _get_scoped_secret("ANDROID_WS_KEY") or extra.get("ws_key", "")
@@ -317,10 +642,19 @@ class AndroidAdapter(BasePlatformAdapter):
) )
self.allow_all = _truthy(os.getenv("ANDROID_ALLOW_ALL_USERS")) self.allow_all = _truthy(os.getenv("ANDROID_ALLOW_ALL_USERS"))
# Runtime state (populated by the WS server in M1) # Runtime state
self._ws_server = None self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db")
self._connections: Dict[str, Any] = {} self._ws_server = WsServer(self, self._devices)
self._connected = False self._connected = False
# M2: per-chat turn state for outbound frame classification.
self._turns: Dict[str, _TurnState] = {}
def _turn_state(self, chat_id: str) -> _TurnState:
st = self._turns.get(chat_id)
if st is None:
st = _TurnState()
self._turns[chat_id] = st
return st
@property @property
def name(self) -> str: def name(self) -> str:
@@ -329,12 +663,7 @@ class AndroidAdapter(BasePlatformAdapter):
# ── Connection lifecycle ────────────────────────────────────────────── # ── Connection lifecycle ──────────────────────────────────────────────
async def connect(self, *, is_reconnect: bool = False) -> bool: async def connect(self, *, is_reconnect: bool = False) -> bool:
"""Bring the platform up. """Bring the platform up: bind the WS server on host:port."""
M0: no WebSocket server yet -- just validate config and mark
connected so ``hermes gateway status`` reflects the platform. M1
starts the ``websockets`` server here.
"""
if not self.token: if not self.token:
logger.error("android: ANDROID_TOKEN must be set") logger.error("android: ANDROID_TOKEN must be set")
self._set_fatal_error( self._set_fatal_error(
@@ -360,21 +689,33 @@ class AndroidAdapter(BasePlatformAdapter):
except ImportError: except ImportError:
self._lock_key = None # status module not available (e.g. tests) self._lock_key = None # status module not available (e.g. tests)
# M1: start the websockets server on host:port (TLS if cert/key set). try:
await self._ws_server.start()
except Exception:
self._connected = False
return False
self._connected = True self._connected = True
self._mark_connected() self._mark_connected()
logger.info("android: connected (skeleton; WS server starts in M1) on %s:%s", self.host, self.port) logger.info("android: connected; WS server on %s:%s", self.host, self.port)
return True return True
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""Tear down the platform.""" """Tear down the platform: stop the server, close device sockets."""
try: try:
from gateway.status import release_scoped_lock from gateway.status import release_scoped_lock
if getattr(self, "_lock_key", None): if getattr(self, "_lock_key", None):
release_scoped_lock("android", self._lock_key) release_scoped_lock("android", self._lock_key)
except ImportError: except ImportError:
pass pass
# M1: stop the server and close all device sockets. try:
await self._ws_server.stop()
except Exception:
logger.warning("android: WS server stop failed", exc_info=True)
try:
self._devices.close()
except Exception:
pass
self._connected = False self._connected = False
self._mark_disconnected() self._mark_disconnected()
logger.info("android: disconnected") logger.info("android: disconnected")
@@ -390,18 +731,277 @@ class AndroidAdapter(BasePlatformAdapter):
) -> SendResult: ) -> SendResult:
"""Send a message to a chat. """Send a message to a chat.
M0: no live devices yet -- log and report success with a minted id. M2: classify the outbound call into a structured frame using the
M1: broadcast a ``message`` frame to connected devices, else fall to per-chat turn state machine (see module docstring):
the outbox + fire push.
* ``metadata["expect_edits"]`` -> ``message.start`` (streaming segment)
* ``metadata["notify"]`` -> ``message`` / ``message.stop`` (final)
* tool-progress line format -> ``tool.start`` (first tool bubble)
* anything else -> ``commentary``
With no live devices the frame is dropped here (the outbox + push
replay lands in M3/M5).
""" """
message_id = f"msg_{uuid.uuid4().hex}" content = content or ""
logger.debug("android: send to %s (%d chars) [skeleton no-op]", chat_id, len(content or "")) meta = metadata or {}
thread_id = _thread_id_from_metadata(meta)
state = self._turn_state(chat_id)
# 1. Streaming segment start (stream consumer first send).
if meta.get("expect_edits") is True:
# A new content segment means the tool the model was waiting on
# has returned -> close it before the segment opens.
await self._close_open_tool(chat_id, state, thread_id)
message_id = _mint_message_id()
state.active = True
state.stream_id = message_id
await self._broadcast_or_log(
chat_id,
protocol.message_start(chat_id, message_id, protocol.ROLE_ASSISTANT, thread_id=thread_id),
)
return SendResult(success=True, message_id=message_id) return SendResult(success=True, message_id=message_id)
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None: # 2. Final message (non-streaming final, or streaming fallback final).
"""Send a typing indicator. M0: no-op (M1 emits a ``typing`` frame).""" if meta.get("notify") is True:
reasoning, body = _split_reasoning(content)
# Non-streaming: reasoning is prepended to content (split above).
# Streaming fallback: content has no reasoning, so use the
# reasoning captured via the on_stream_delta hook (wait for the
# async hook worker to flush it first).
if not reasoning:
await _wait_for_reasoning_flushed()
reasoning = _take_reasoning() or None
else:
_reset_reasoning()
if state.stream_id:
# Fallback final: close the open streaming segment in place.
message_id = state.stream_id
state.stream_id = None
await self._broadcast_or_log(
chat_id,
protocol.message_stop(
chat_id, message_id, body,
reasoning=reasoning, thread_id=thread_id,
ts=int(time.time() * 1000),
),
)
else:
message_id = _mint_message_id()
await self._broadcast_or_log(
chat_id,
protocol.message(
chat_id=chat_id,
message_id=message_id,
role=protocol.ROLE_ASSISTANT,
text=body,
thread_id=thread_id,
reasoning=reasoning,
reply_to=reply_to,
ts=int(time.time() * 1000),
),
)
await self._close_open_tool(chat_id, state, thread_id)
self._reset_tool_state(state)
state.active = False
return SendResult(success=True, message_id=message_id)
# 3. Tool progress (first tool bubble of an editable line buffer).
if _is_tool_progress(content):
return await self._emit_tool_lines(chat_id, content, state, thread_id, is_edit=False)
# 4. Commentary (interim assistant beat).
message_id = _mint_message_id()
state.active = True
await self._broadcast_or_log(
chat_id,
protocol.commentary(chat_id, message_id, content, thread_id=thread_id),
)
return SendResult(success=True, message_id=message_id)
async def edit_message(
self,
chat_id: str,
message_id: str,
content: str,
*,
finalize: bool = False,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Edit a previously sent message (M2: drives streaming + tool updates).
* ``message_id == state.stream_id`` -> ``message.update``
(``finalize=True`` -> ``message.stop``).
* ``message_id == state.tool_msg_id`` -> tool-progress update
(new lines -> ``tool.start``).
* unknown id -> best-effort ``message.update``.
"""
content = content or ""
thread_id = _thread_id_from_metadata(metadata)
state = self._turn_state(chat_id)
if message_id and message_id == state.stream_id:
if finalize:
reasoning, body = _split_reasoning(_strip_streaming_cursor(content))
# Streaming: the gateway drops the model's separate
# reasoning_content (final send suppressed), so attach the
# reasoning we captured via the on_stream_delta hook (wait
# for the async hook worker to flush it first).
if not reasoning:
await _wait_for_reasoning_flushed()
reasoning = _take_reasoning() or None
else:
_reset_reasoning()
state.stream_id = None
await self._broadcast_or_log(
chat_id,
protocol.message_stop(
chat_id, message_id, body,
reasoning=reasoning, thread_id=thread_id,
ts=int(time.time() * 1000),
),
)
await self._close_open_tool(chat_id, state, thread_id)
self._reset_tool_state(state)
state.active = False
else:
await self._broadcast_or_log(
chat_id,
protocol.message_update(
chat_id, message_id, _strip_streaming_cursor(content),
thread_id=thread_id,
),
)
return SendResult(success=True, message_id=message_id)
if message_id and message_id == state.tool_msg_id:
return await self._emit_tool_lines(chat_id, content, state, thread_id, is_edit=True)
# Unknown id: treat as a streaming update (best effort).
if finalize:
await self._broadcast_or_log(
chat_id,
protocol.message_stop(
chat_id, message_id, _strip_streaming_cursor(content),
thread_id=thread_id, ts=int(time.time() * 1000),
),
)
else:
await self._broadcast_or_log(
chat_id,
protocol.message_update(
chat_id, message_id, _strip_streaming_cursor(content),
thread_id=thread_id,
),
)
return SendResult(success=True, message_id=message_id)
# ── M2: tool-progress helpers ─────────────────────────────────────────
async def _emit_tool_lines(
self,
chat_id: str,
content: str,
state: _TurnState,
thread_id: Optional[str],
*,
is_edit: bool,
) -> SendResult:
"""Emit ``tool.start`` for each NEW tool line in *content*.
The gateway accumulates tool lines in one editable bubble; on an edit
the full buffer is re-sent, so we diff against ``seen_tool_lines`` to
emit only the new ones. A new tool closes the previously-open tool.
"""
message_id = state.tool_msg_id or _mint_message_id()
state.tool_msg_id = message_id
state.active = True
lines = [ln for ln in content.splitlines() if ln.strip()]
for line in lines:
key = line.strip()
if key in state.seen_tool_lines:
continue
state.seen_tool_lines.add(key)
parsed = self._parse_tool_line_or_block(line, content)
if parsed is None:
continue
name, preview = parsed
# A new tool begins: close the previously-open one.
if state.open_tool_index is not None:
await self._broadcast_or_log(
chat_id,
protocol.tool_end(chat_id, state.open_tool_index, "", ok=True, thread_id=thread_id),
)
state.tool_index += 1
state.open_tool_index = state.tool_index
await self._broadcast_or_log(
chat_id,
protocol.tool_start(
chat_id, state.tool_index, name,
preview=preview, thread_id=thread_id,
),
)
return SendResult(success=True, message_id=message_id)
@staticmethod
def _parse_tool_line_or_block(line: str, content: str) -> Optional[Tuple[str, Optional[str]]]:
"""Parse a tool line, expanding a terminal code block to its command."""
parsed = _parse_tool_line(line)
if parsed is not None:
name, preview = parsed
# Terminal code block: the command lives in the fenced lines that
# follow the "<emoji> terminal" head line.
if name == "terminal" and preview is None and "```" in content:
cmd = _extract_code_block(content)
if cmd:
return name, cmd
return parsed
return None return None
async def _close_open_tool(
self, chat_id: str, state: _TurnState, thread_id: Optional[str]
) -> None:
"""Emit ``tool.end`` for the currently-open tool, if any.
A tool is considered complete when the next tool starts OR a new
content segment begins (the model only produces content after the
tool it was waiting on has returned).
"""
if state.open_tool_index is not None:
await self._broadcast_or_log(
chat_id,
protocol.tool_end(chat_id, state.open_tool_index, "", ok=True, thread_id=thread_id),
)
state.open_tool_index = None
def _reset_tool_state(self, state: _TurnState) -> None:
"""Clear per-turn tool bookkeeping (called at turn finalization)."""
state.tool_msg_id = None
state.seen_tool_lines = set()
state.tool_index = 0
state.open_tool_index = None
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
delivered = await self._ws_server.broadcast(frame)
if delivered == 0:
logger.info(
"android: no live devices for %s; %s frame not delivered (outbox lands in M3)",
chat_id, frame.type,
)
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
"""Send a typing indicator (``typing`` frame, on=true)."""
thread_id = None
if metadata:
tid = metadata.get("thread_id")
if isinstance(tid, str) and tid:
thread_id = tid
await self._ws_server.broadcast(protocol.typing(chat_id, True, thread_id=thread_id))
async def stop_typing(self, chat_id: str) -> None:
"""Clear the typing indicator (``typing`` frame, on=false)."""
await self._ws_server.broadcast(protocol.typing(chat_id, False))
async def send_image( async def send_image(
self, self,
chat_id: str, chat_id: str,
@@ -410,19 +1010,120 @@ class AndroidAdapter(BasePlatformAdapter):
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult: ) -> SendResult:
"""Send an image. M0: not implemented (M4).""" """Send an image. M1: not implemented (M4)."""
return SendResult(success=False, error="android: media not implemented yet (M4)") return SendResult(success=False, error="android: media not implemented yet (M4)")
# ── Inbound (app -> agent) ────────────────────────────────────────────
async def on_message_send(self, frame: protocol.Frame, device_id: str) -> None:
"""Handle an inbound ``message.send`` frame.
Echoes the user message to all devices (multi-device sync + ack),
then builds a ``MessageEvent`` and hands it to ``handle_message()``
(the gateway's command pipeline + agent turn).
"""
payload = frame.payload
text = payload.get("text")
if not isinstance(text, str) or not text.strip():
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_UNSUPPORTED, "message.send requires non-empty text", id=frame.id),
)
return
chat_id = frame.chat_id or payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
chat_id = self.home_channel
chat_id = chat_id.strip()
thread_id = frame.thread_id or payload.get("thread_id")
if not isinstance(thread_id, str) or not thread_id.strip():
thread_id = None
reply_to = payload.get("reply_to")
if not isinstance(reply_to, str) or not reply_to.strip():
reply_to = None
device = self._devices.get(device_id) or {}
user_name = device.get("name") or device_id
# Echo to all devices: the sender confirms (server-assigned id),
# other devices see the message too (single-user, multi-device).
message_id = f"m_{uuid.uuid4().hex[:16]}"
echo = protocol.message(
chat_id=chat_id,
message_id=message_id,
role=protocol.ROLE_USER,
text=text,
thread_id=thread_id,
reply_to=reply_to,
ts=int(time.time() * 1000),
)
await self._ws_server.broadcast(echo)
source = self.build_source(
chat_id=chat_id,
chat_name=self._channel_name(chat_id),
chat_type="dm",
user_id=device_id,
user_name=user_name,
thread_id=thread_id,
)
event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
user_id=device_id,
user_name=user_name,
source=source,
message_id=message_id,
reply_to_message_id=reply_to,
)
await self.handle_message(event)
# ── Chat info ───────────────────────────────────────────────────────── # ── Chat info ─────────────────────────────────────────────────────────
def _channel_name(self, chat_id: str) -> str:
"""Channel display name. M1: home channel only (directory is M3)."""
if chat_id in (self.home_channel, DEFAULT_HOME_CHANNEL):
return self.home_channel_name
return chat_id or "chat"
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Return ``{name, type, chat_id}`` for a chat. """Return ``{name, type, chat_id}`` for a chat.
M0: the channel directory is not persisted yet, so report the home M1: the channel directory is not persisted yet, so report the home
channel name for the default chat and a generic name otherwise. channel name for the default chat and the raw id otherwise.
""" """
name = "Default" if chat_id in (self.home_channel, DEFAULT_HOME_CHANNEL) else (chat_id or "chat") return {
return {"name": name, "type": "channel", "chat_id": chat_id} "name": self._channel_name(chat_id),
"type": "channel",
"chat_id": chat_id,
}
# ── hello.ack helpers ─────────────────────────────────────────────────
def server_caps(self) -> Dict[str, Any]:
"""Capability flags advertised in ``hello.ack`` (M2 surface)."""
return {
"streaming": True, # M2: message.start/update/stop
"reasoning": True, # M2: reasoning field on message / message.stop
"tools": True, # M2: tool.start/progress/end
"media": False, # M4
"search": False, # M3
"push": self.push_backend,
"pickers": False, # M2+
}
def channel_list(self) -> List[Dict[str, Any]]:
"""Channel directory for ``hello.ack``. M1: home channel only (M3)."""
return [
{
"chat_id": self.home_channel,
"name": self.home_channel_name,
"kind": "default",
"is_default": True,
}
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -431,6 +1132,13 @@ class AndroidAdapter(BasePlatformAdapter):
def register(ctx): def register(ctx):
"""Plugin entry point: called by the Hermes plugin system.""" """Plugin entry point: called by the Hermes plugin system."""
# M2: capture the model's separate reasoning_content during streaming so
# it can be attached to the turn's message.stop frame (the gateway
# otherwise drops it when streaming suppresses the final send).
try:
ctx.register_hook("on_stream_delta", _on_stream_delta)
except Exception:
logger.debug("android: on_stream_delta hook registration failed", exc_info=True)
ctx.register_platform( ctx.register_platform(
name="android", name="android",
label="Android", label="Android",
+179
View File
@@ -8,3 +8,182 @@ Storage: ``get_hermes_home()/"android"/devices.db``.
Milestone M1. Milestone M1.
""" """
import hmac
import json
import logging
import secrets
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib.parse import quote
logger = logging.getLogger(__name__)
# 32 random bytes -> 64 hex chars (docs/09-pairing-security.md)
TOKEN_BYTES = 32
def generate_token() -> str:
"""Mint a fresh high-entropy pairing token (64 hex chars)."""
return secrets.token_hex(TOKEN_BYTES)
def verify_token(provided: Optional[str], expected: Optional[str]) -> bool:
"""Constant-time token comparison (never time-leaks the token)."""
if not provided or not expected:
return False
return hmac.compare_digest(
provided.encode("utf-8", "replace"),
expected.encode("utf-8", "replace"),
)
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
"""Pairing URL encoded into the QR / pre-filled into the app.
``iris://pair?host=<lan-ip>&port=8790&token=<token>`` — the app's
Connect screen parses this to pre-fill settings (docs/09 §9.2).
"""
return (
f"iris://pair?host={quote(host, safe='')}"
f"&port={int(port)}"
f"&secure={'1' if secure else '0'}"
f"&token={quote(token, safe='')}"
)
def pairing_url(host: str, port: int, secure: bool = False) -> str:
"""Plain ws(s) URL the app connects to (shown next to the QR)."""
scheme = "wss" if secure else "ws"
return f"{scheme}://{host}:{int(port)}/ws"
# ---------------------------------------------------------------------------
# Device registry (SQLite)
# ---------------------------------------------------------------------------
class DeviceRegistry:
"""Persistent device registry under ``get_hermes_home()/"android"``.
Thread-safe (single connection + lock); all operations are small and
fast enough to run inline on the gateway's asyncio loop.
"""
def __init__(self, db_path: Path):
self._db_path = Path(db_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
with self._lock:
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
caps TEXT NOT NULL DEFAULT '{}',
fcm_token TEXT,
ntfy_topic TEXT,
last_seen REAL NOT NULL DEFAULT 0,
created REAL NOT NULL DEFAULT 0
)
"""
)
self._conn.commit()
def upsert(
self,
device_id: str,
name: str,
caps: Optional[Dict[str, Any]] = None,
fcm_token: Optional[str] = None,
ntfy_topic: Optional[str] = None,
) -> None:
now = time.time()
caps_json = json.dumps(caps or {}, separators=(",", ":"))
with self._lock:
self._conn.execute(
"""
INSERT INTO devices (device_id, name, caps, fcm_token, ntfy_topic,
last_seen, created)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET
name = excluded.name,
caps = excluded.caps,
fcm_token = COALESCE(excluded.fcm_token, devices.fcm_token),
ntfy_topic = COALESCE(excluded.ntfy_topic, devices.ntfy_topic),
last_seen = excluded.last_seen
""",
(device_id, name or "", caps_json, fcm_token, ntfy_topic, now, now),
)
self._conn.commit()
def update_push_tokens(
self,
device_id: str,
fcm_token: Optional[str] = None,
ntfy_topic: Optional[str] = None,
) -> None:
with self._lock:
self._conn.execute(
"""
UPDATE devices SET
fcm_token = COALESCE(?, fcm_token),
ntfy_topic = COALESCE(?, ntfy_topic),
last_seen = ?
WHERE device_id = ?
""",
(fcm_token, ntfy_topic, time.time(), device_id),
)
self._conn.commit()
def touch(self, device_id: str) -> None:
with self._lock:
self._conn.execute(
"UPDATE devices SET last_seen = ? WHERE device_id = ?",
(time.time(), device_id),
)
self._conn.commit()
def get(self, device_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
row = self._conn.execute(
"SELECT * FROM devices WHERE device_id = ?", (device_id,)
).fetchone()
return _row_to_device(row) if row else None
def list(self) -> List[Dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM devices ORDER BY last_seen DESC"
).fetchall()
return [_row_to_device(r) for r in rows]
def close(self) -> None:
with self._lock:
try:
self._conn.close()
except Exception:
pass
def _row_to_device(row: sqlite3.Row) -> Dict[str, Any]:
try:
caps = json.loads(row["caps"] or "{}")
if not isinstance(caps, dict):
caps = {}
except (json.JSONDecodeError, TypeError):
caps = {}
return {
"device_id": row["device_id"],
"name": row["name"],
"caps": caps,
"fcm_token": row["fcm_token"],
"ntfy_topic": row["ntfy_topic"],
"last_seen": row["last_seen"],
"created": row["created"],
}
+344 -3
View File
@@ -5,18 +5,359 @@ constants. ``docs/protocol/frames.schema.json`` is generated/mirrored from
this module, and the Kotlin side mirrors these shapes (see this module, and the Kotlin side mirrors these shapes (see
``docs/04-wire-protocol.md``). ``docs/04-wire-protocol.md``).
Milestone M1: hello/hello.ack, message, error, ping/pong. Milestone M1: hello/hello.ack, message, message.send, error, ping/pong,
Milestone M2: message.start/update/stop, reasoning, tool.*, commentary, typing. typing (typing is pulled forward from M2 so the app gets a live
"working…" indicator during the first milestone).
Milestone M2: message.start/update/stop, reasoning (on message /
message.stop), tool.start/progress/end, commentary.
Milestone M3: channel.*, search, sync. Milestone M3: channel.*, search, sync.
Milestone M4: media.*. Milestone M4: media.*.
Milestone M5: notification, fcm.register, read.receipt. Milestone M5: notification, fcm.register, read.receipt.
""" """
import json
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
PROTOCOL_VERSION = 1 PROTOCOL_VERSION = 1
# Frame type constants (the ``type`` field of every frame). # ---------------------------------------------------------------------------
# Frame type constants (the ``type`` field of every frame)
# ---------------------------------------------------------------------------
# Pairing / lifecycle
TYPE_HELLO = "hello" TYPE_HELLO = "hello"
TYPE_HELLO_ACK = "hello.ack" TYPE_HELLO_ACK = "hello.ack"
TYPE_ERROR = "error" TYPE_ERROR = "error"
TYPE_PING = "ping" TYPE_PING = "ping"
TYPE_PONG = "pong" TYPE_PONG = "pong"
# Chat
TYPE_MESSAGE = "message"
TYPE_MESSAGE_SEND = "message.send"
TYPE_TYPING = "typing"
# Streaming (M2)
TYPE_MESSAGE_START = "message.start"
TYPE_MESSAGE_UPDATE = "message.update"
TYPE_MESSAGE_STOP = "message.stop"
# Tool activity (M2)
TYPE_TOOL_START = "tool.start"
TYPE_TOOL_PROGRESS = "tool.progress"
TYPE_TOOL_END = "tool.end"
# Intermediate assistant beat (M2)
TYPE_COMMENTARY = "commentary"
# ---------------------------------------------------------------------------
# Error codes (``error`` frame payload.code)
# ---------------------------------------------------------------------------
ERR_AUTH = "auth"
ERR_NOT_FOUND = "not_found"
ERR_RATE_LIMITED = "rate_limited"
ERR_MEDIA_TOO_LARGE = "media_too_large"
ERR_UNSUPPORTED = "unsupported"
ERR_INTERNAL = "internal"
# ---------------------------------------------------------------------------
# Message roles (``message`` frame payload.role)
# ---------------------------------------------------------------------------
ROLE_USER = "user"
ROLE_ASSISTANT = "assistant"
ROLE_SYSTEM = "system"
ROLE_CRON = "cron"
# ---------------------------------------------------------------------------
# Envelope
# ---------------------------------------------------------------------------
@dataclass
class Frame:
"""One wire frame.
``v`` is always serialised; ``id``/``chat_id``/``thread_id`` are
omitted when ``None`` (events carry no ``id``; chat-scoped frames carry
``chat_id``/``thread_id`` at the top level for convenience).
"""
type: str
payload: Dict[str, Any] = field(default_factory=dict)
id: Optional[int] = None
chat_id: Optional[str] = None
thread_id: Optional[str] = None
v: int = PROTOCOL_VERSION
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {"v": self.v, "type": self.type}
if self.id is not None:
d["id"] = self.id
if self.chat_id is not None:
d["chat_id"] = self.chat_id
if self.thread_id is not None:
d["thread_id"] = self.thread_id
d["payload"] = self.payload
return d
def to_json(self) -> str:
return json.dumps(self.to_dict(), separators=(",", ":"), ensure_ascii=False)
@classmethod
def from_json(cls, raw: "str | bytes") -> Optional["Frame"]:
"""Parse a text frame. Returns ``None`` for anything not a valid
frame (bad JSON, non-object, missing/invalid ``type``) so callers
can ignore malformed input (forward-compat)."""
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError, UnicodeDecodeError, ValueError):
return None
if not isinstance(data, dict):
return None
ftype = data.get("type")
if not isinstance(ftype, str) or not ftype:
return None
payload = data.get("payload")
if not isinstance(payload, dict):
payload = {}
fid = data.get("id")
if not isinstance(fid, int) or isinstance(fid, bool):
fid = None
chat_id = data.get("chat_id")
if not isinstance(chat_id, str):
chat_id = None
thread_id = data.get("thread_id")
if not isinstance(thread_id, str):
thread_id = None
return cls(type=ftype, payload=payload, id=fid, chat_id=chat_id, thread_id=thread_id)
# ---------------------------------------------------------------------------
# Frame constructors (server -> app)
# ---------------------------------------------------------------------------
def hello_ack(
server_caps: Dict[str, Any],
sync_cursor: int = 0,
channels: Optional[list] = None,
) -> Frame:
return Frame(
type=TYPE_HELLO_ACK,
payload={
"server_caps": server_caps,
"sync_cursor": sync_cursor,
"channels": channels or [],
},
)
def message(
chat_id: str,
message_id: str,
role: str,
text: str,
*,
thread_id: Optional[str] = None,
reasoning: Optional[str] = None,
media: Optional[list] = None,
reply_to: Optional[str] = None,
model: Optional[str] = None,
tokens: Optional[int] = None,
ts: Optional[int] = None,
) -> Frame:
payload: Dict[str, Any] = {
"message_id": message_id,
"role": role,
"text": text,
}
if reasoning:
payload["reasoning"] = reasoning
if media:
payload["media"] = media
if reply_to:
payload["reply_to"] = reply_to
if model:
payload["model"] = model
if tokens is not None:
payload["tokens"] = tokens
if ts is not None:
payload["ts"] = ts
return Frame(type=TYPE_MESSAGE, chat_id=chat_id, thread_id=thread_id, payload=payload)
def typing(chat_id: str, on: bool = True, *, thread_id: Optional[str] = None) -> Frame:
return Frame(
type=TYPE_TYPING,
chat_id=chat_id,
thread_id=thread_id,
payload={"on": on},
)
# ---------------------------------------------------------------------------
# Streaming frames (M2)
# ---------------------------------------------------------------------------
def message_start(
chat_id: str,
message_id: str,
role: str = ROLE_ASSISTANT,
*,
thread_id: Optional[str] = None,
) -> Frame:
"""Open a streaming bubble."""
return Frame(
type=TYPE_MESSAGE_START,
chat_id=chat_id,
thread_id=thread_id,
payload={"message_id": message_id, "role": role},
)
def message_update(
chat_id: str,
message_id: str,
text: str,
*,
thread_id: Optional[str] = None,
) -> Frame:
"""Replace the live bubble text (full snapshot)."""
return Frame(
type=TYPE_MESSAGE_UPDATE,
chat_id=chat_id,
thread_id=thread_id,
payload={"message_id": message_id, "text": text},
)
def message_stop(
chat_id: str,
message_id: str,
final_text: str,
*,
thread_id: Optional[str] = None,
reasoning: Optional[str] = None,
model: Optional[str] = None,
tokens: Optional[int] = None,
ts: Optional[int] = None,
) -> Frame:
"""Finalize a streaming bubble."""
payload: Dict[str, Any] = {
"message_id": message_id,
"final_text": final_text,
}
if reasoning:
payload["reasoning"] = reasoning
if model:
payload["model"] = model
if tokens is not None:
payload["tokens"] = tokens
if ts is not None:
payload["ts"] = ts
return Frame(
type=TYPE_MESSAGE_STOP,
chat_id=chat_id,
thread_id=thread_id,
payload=payload,
)
# ---------------------------------------------------------------------------
# Tool activity frames (M2)
# ---------------------------------------------------------------------------
def tool_start(
chat_id: str,
index: int,
name: str,
*,
thread_id: Optional[str] = None,
preview: Optional[str] = None,
args: Optional[Dict[str, Any]] = None,
) -> Frame:
payload: Dict[str, Any] = {"index": index, "name": name}
if preview:
payload["preview"] = preview
if args:
payload["args"] = args
return Frame(
type=TYPE_TOOL_START,
chat_id=chat_id,
thread_id=thread_id,
payload=payload,
)
def tool_progress(
chat_id: str,
index: int,
name: str,
*,
thread_id: Optional[str] = None,
note: Optional[str] = None,
) -> Frame:
payload: Dict[str, Any] = {"index": index, "name": name}
if note:
payload["note"] = note
return Frame(
type=TYPE_TOOL_PROGRESS,
chat_id=chat_id,
thread_id=thread_id,
payload=payload,
)
def tool_end(
chat_id: str,
index: int,
name: str,
*,
thread_id: Optional[str] = None,
ok: bool = True,
duration: Optional[float] = None,
output_preview: Optional[str] = None,
) -> Frame:
payload: Dict[str, Any] = {"index": index, "name": name, "ok": ok}
if duration is not None:
payload["duration"] = duration
if output_preview:
payload["output_preview"] = output_preview
return Frame(
type=TYPE_TOOL_END,
chat_id=chat_id,
thread_id=thread_id,
payload=payload,
)
# ---------------------------------------------------------------------------
# Commentary frame (M2)
# ---------------------------------------------------------------------------
def commentary(
chat_id: str,
message_id: str,
text: str,
*,
thread_id: Optional[str] = None,
) -> Frame:
"""An intermediate assistant beat (between tool iterations)."""
return Frame(
type=TYPE_COMMENTARY,
chat_id=chat_id,
thread_id=thread_id,
payload={"message_id": message_id, "text": text},
)
def error(code: str, message: str, *, id: Optional[int] = None) -> Frame:
return Frame(type=TYPE_ERROR, id=id, payload={"code": code, "message": message})
def pong(ts: Optional[int] = None) -> Frame:
payload: Dict[str, Any] = {}
if ts is not None:
payload["ts"] = ts
return Frame(type=TYPE_PONG, payload=payload)
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""WS test-client harness (docs/13-testing.md §13.2).
Connects to the REAL running gateway and drives a turn, printing every
frame. This is how we empirically confirm the exact frame shapes before /
while building the Kotlin client.
Usage::
hermes gateway & # with the android plugin
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \
--send "hello"
Options:
--url ws://host:port/ws (default ws://127.0.0.1:8790/ws)
--token ANDROID_TOKEN (default: $ANDROID_TOKEN)
--device device_id (default: probe-<rand>)
--send TEXT send this message after pairing (default: "hello")
--timeout S seconds to wait for the final reply (default 120)
--authfail expect an auth rejection (wrong token) and exit 0 on it
"""
import argparse
import asyncio
import json
import os
import sys
import time
import uuid
try:
import websockets
except ImportError: # pragma: no cover
sys.stderr.write("websockets is required (hermes core dep); run inside the hermes venv\n")
raise
def _print_frame(raw):
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
print(f" <- {raw!r}")
return None
ftype = data.get("type", "?")
chat = data.get("chat_id")
fid = data.get("id")
payload = data.get("payload", {})
# Compact one-line summary + full payload for the interesting frames.
extra = ""
if ftype == "message":
text = (payload.get("text") or "")
extra = f" role={payload.get('role')} id={payload.get('message_id')} text={text[:120]!r}"
if payload.get("reasoning"):
extra += f" reasoning={payload['reasoning'][:80]!r}"
elif ftype == "message.start":
extra = f" id={payload.get('message_id')} role={payload.get('role')}"
elif ftype == "message.update":
text = (payload.get("text") or "")
extra = f" id={payload.get('message_id')} text={text[:100]!r}"
elif ftype == "message.stop":
text = (payload.get("final_text") or "")
extra = f" id={payload.get('message_id')} text={text[:120]!r}"
if payload.get("reasoning"):
extra += f" reasoning={payload['reasoning'][:80]!r}"
elif ftype == "tool.start":
extra = (f" idx={payload.get('index')} name={payload.get('name')!r} "
f"preview={str(payload.get('preview'))[:80]!r}")
elif ftype == "tool.progress":
extra = f" idx={payload.get('index')} name={payload.get('name')!r} note={payload.get('note')!r}"
elif ftype == "tool.end":
extra = (f" idx={payload.get('index')} name={payload.get('name')!r} "
f"ok={payload.get('ok')} dur={payload.get('duration')}")
elif ftype == "commentary":
extra = f" id={payload.get('message_id')} text={(payload.get('text') or '')[:120]!r}"
elif ftype == "hello.ack":
extra = f" caps={payload.get('server_caps')} cursor={payload.get('sync_cursor')}"
elif ftype == "error":
extra = f" code={payload.get('code')} msg={payload.get('message')!r}"
elif ftype == "typing":
extra = f" on={payload.get('on')}"
elif ftype == "pong":
extra = ""
scope = f" chat={chat}" if chat else ""
idpart = f" id={fid}" if fid is not None else ""
print(f" <- {ftype}{idpart}{scope}{extra}")
return data
async def run(args) -> int:
url = args.url
token = args.token
device_id = args.device
print(f"== ws_probe: connecting {url} device={device_id}")
try:
ws = await websockets.connect(url, open_timeout=10)
except Exception as e:
print(f"!! connect failed: {e}")
return 2
hello = {
"v": 1,
"type": "hello",
"payload": {
"token": token,
"device_id": device_id,
"device_name": "ws-probe",
"caps": {"min_protocol": 1},
},
}
await ws.send(json.dumps(hello))
print(" -> hello")
# First response must be hello.ack (or an auth error).
try:
first = await asyncio.wait_for(ws.recv(), timeout=10)
except asyncio.TimeoutError:
print("!! no hello.ack within 10s")
await ws.close()
return 3
data = _print_frame(first)
if data is None or data.get("type") != "hello.ack":
if args.authfail:
print("== auth rejected as expected")
await ws.close()
return 0
print("!! expected hello.ack")
await ws.close()
return 4
if args.authfail:
print("!! expected auth rejection but got hello.ack")
await ws.close()
return 5
if not args.send:
print("== paired OK (no --send; exiting)")
await ws.close()
return 0
# Drive a turn.
msg_id = 1
send_frame = {
"v": 1,
"id": msg_id,
"type": "message.send",
"chat_id": "android:default",
"payload": {"text": args.send},
}
await ws.send(json.dumps(send_frame))
print(f" -> message.send id={msg_id} text={args.send!r}")
deadline = time.time() + args.timeout
got_final = False
seen_final_frame = False
while time.time() < deadline:
try:
raw = await asyncio.wait_for(ws.recv(), timeout=deadline - time.time())
except asyncio.TimeoutError:
print(f"!! timeout after {args.timeout}s waiting for final message")
await ws.close()
return 6
data = _print_frame(raw)
if data is None:
continue
ftype = data.get("type")
payload = data.get("payload") or {}
# A standalone assistant `message` (non-streaming) is immediately final.
if ftype == "message" and payload.get("role") == "assistant":
got_final = True
break
# A `message.stop` finalizes a streaming segment; the turn is done once
# typing stops afterwards (multi-segment turns have several stops).
if ftype == "message.stop":
seen_final_frame = True
if ftype == "typing" and payload.get("on") is False and seen_final_frame:
got_final = True
break
await ws.close()
if got_final:
print("== final assistant message received")
return 0
print("!! no final assistant message")
return 7
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", "ws://127.0.0.1:8790/ws"))
p.add_argument("--token", default=os.getenv("ANDROID_TOKEN", ""))
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
p.add_argument("--send", default="hello")
p.add_argument("--timeout", type=float, default=120.0)
p.add_argument("--authfail", action="store_true",
help="expect an auth rejection (wrong token)")
args = p.parse_args()
if not args.token and not args.authfail:
p.error("--token (or $ANDROID_TOKEN) is required")
return asyncio.run(run(args))
if __name__ == "__main__":
sys.exit(main())
+299 -14
View File
@@ -1,24 +1,309 @@
"""WebSocket server, connection registry, and frame routing. """WebSocket server, connection registry, and frame routing.
Runs on the gateway's asyncio loop (started in ``AndroidAdapter.connect()``). Runs on the gateway's asyncio loop (started in ``AndroidAdapter.connect()``).
Uses the ``websockets`` core dep (v15): ``websockets.serve(handler, host, Uses the ``websockets`` core dep (v15): ``websockets.asyncio.server.serve(
port, ssl=ctx)``. handler, host, port, ssl=ctx)``.
Per-connection handler: Per-connection handler:
1. Await first frame; must be ``hello {token, device_id, device_name, 1. Await first frame (bounded); must be ``hello {token, device_id,
caps, fcm_token?}``. Verify token (constant-time) + allowlist. On device_name, caps, fcm_token?}``. Verify token (constant-time) +
failure: send ``error {code:"auth"}`` and close. allowlist. On failure: send ``error {code:"auth"}`` and close.
2. On success: register in the connection registry (``device_id -> 2. On success: register in the device registry (SQLite) + connection
{ws, caps, fcm_token}``), send ``hello.ack {server_caps, sync_cursor, registry (``device_id -> {ws, caps, fcm_token}``), send
channels[]}``. ``hello.ack {server_caps, sync_cursor, channels[]}``.
3. Loop: decode frames, dispatch to adapter inbound handlers. 3. Loop: decode frames, dispatch to adapter inbound handlers.
4. On close: deregister; if no devices remain, ensure pending outbox 4. On close: deregister.
frames have push fired.
Routing: ``emit(chat_id, frame)`` broadcasts to ALL connected devices Routing: ``broadcast(frame)`` sends to ALL connected devices (single-user
(single-user model). Heartbeat via WS ping/pong + app-level ping/pong. model). Heartbeat via WS ping/pong (websockets built-in) + app-level
Backpressure: bounded per-connection send queue; coalesce ``message.update`` ``ping``/``pong`` frames.
under pressure, never drop ``message``/``tool.end``/``notification``.
Milestone M1. Milestone M1.
""" """
import asyncio
import logging
import ssl
import time
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
from websockets.asyncio.server import ServerConnection, serve
from websockets.exceptions import ConnectionClosed
from . import protocol
from .pairing import DeviceRegistry, verify_token
logger = logging.getLogger(__name__)
# How long a new socket may take to present its ``hello`` before we drop it.
HELLO_TIMEOUT_S = 10.0
# Close codes (4000-4999 are reserved for applications).
CLOSE_AUTH_FAILED = 4401
CLOSE_REPLACED = 4402
CLOSE_SHUTDOWN = 1001
@dataclass
class DeviceConnection:
"""One live, authenticated device socket."""
device_id: str
device_name: str
ws: ServerConnection
caps: Dict[str, Any] = field(default_factory=dict)
fcm_token: Optional[str] = None
ntfy_topic: Optional[str] = None
connected_at: float = field(default_factory=time.time)
class WsServer:
"""The plugin's WebSocket server + live connection registry."""
def __init__(self, adapter: Any, devices: DeviceRegistry):
self._adapter = adapter
self._devices = devices
self._server: Optional[Any] = None
self._connections: Dict[str, DeviceConnection] = {}
self._lock = asyncio.Lock()
# ── Lifecycle ─────────────────────────────────────────────────────────
async def start(self) -> None:
"""Bind and start serving. Raises on bind failure (adapter maps it
to a retryable fatal error)."""
adapter = self._adapter
ssl_ctx: Optional[ssl.SSLContext] = None
if adapter.ws_cert and adapter.ws_key:
try:
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_ctx.load_cert_chain(adapter.ws_cert, adapter.ws_key)
except Exception as e:
adapter._set_fatal_error(
"tls_config", f"WS TLS cert/key invalid: {e}", retryable=False
)
raise
try:
self._server = await serve(
self._handler,
adapter.host,
adapter.port,
ssl=ssl_ctx,
# Media uploads (M4) are chunked binary frames; allow the
# configured max upload size per frame.
max_size=adapter.max_upload_bytes,
# WS-level heartbeat: dead peers are reaped by websockets.
ping_interval=20,
ping_timeout=20,
open_timeout=10,
)
except OSError as e:
adapter._set_fatal_error(
"bind_failed", f"WS bind on {adapter.host}:{adapter.port} failed: {e}",
retryable=True,
)
raise
scheme = "wss" if ssl_ctx else "ws"
logger.info(
"android: WS server listening on %s://%s:%s/ws",
scheme, adapter.host, adapter.port,
)
async def stop(self) -> None:
"""Stop serving and close all device sockets."""
if self._server is not None:
self._server.close()
try:
await self._server.wait_closed()
except Exception:
pass
self._server = None
for conn in list(self._connections.values()):
try:
await conn.ws.close(code=CLOSE_SHUTDOWN, reason="gateway shutting down")
except Exception:
pass
self._connections.clear()
# ── Registry ──────────────────────────────────────────────────────────
@property
def connections(self) -> Dict[str, DeviceConnection]:
return dict(self._connections)
def has_devices(self) -> bool:
return bool(self._connections)
def device_ids(self) -> list:
return list(self._connections.keys())
# ── Outbound ──────────────────────────────────────────────────────────
async def broadcast(self, frame: protocol.Frame) -> int:
"""Send a frame to every connected device. Returns devices reached.
Best-effort: a dead socket is skipped (deregistered on its own close)."""
data = frame.to_json()
sent = 0
for conn in list(self._connections.values()):
try:
await conn.ws.send(data)
sent += 1
except Exception:
pass
return sent
async def send_to(self, device_id: str, frame: protocol.Frame) -> bool:
"""Send a frame to one device (request responses / errors)."""
conn = self._connections.get(device_id)
if conn is None:
return False
try:
await conn.ws.send(frame.to_json())
return True
except Exception:
return False
# ── Per-connection handler ────────────────────────────────────────────
async def _handler(self, ws: ServerConnection) -> None:
# 1. hello auth -----------------------------------------------------
try:
raw = await asyncio.wait_for(ws.recv(), timeout=HELLO_TIMEOUT_S)
except asyncio.TimeoutError:
logger.warning("android: dropping socket with no hello (timeout)")
await self._close_quiet(ws, 1000, "no hello")
return
except ConnectionClosed:
return
frame = protocol.Frame.from_json(raw)
if frame is None or frame.type != protocol.TYPE_HELLO:
await self._reject(ws, "first frame must be hello")
return
payload = frame.payload
if not verify_token(payload.get("token"), self._adapter.token):
peer = getattr(ws, "remote_address", None)
logger.warning("android: hello rejected: invalid token (peer=%s)", peer)
await self._reject(ws, "invalid token")
return
device_id = str(payload.get("device_id") or "").strip()
if not device_id or len(device_id) > 128:
await self._reject(ws, "device_id required")
return
if (
not self._adapter.allow_all
and self._adapter.allowed_users
and device_id not in self._adapter.allowed_users
):
logger.warning("android: hello rejected: device %s not allowlisted", device_id)
await self._reject(ws, "device not allowed")
return
device_name = str(payload.get("device_name") or device_id)[:120]
caps = payload.get("caps")
if not isinstance(caps, dict):
caps = {}
fcm_token = payload.get("fcm_token")
if not isinstance(fcm_token, str):
fcm_token = None
ntfy_topic = payload.get("ntfy_topic")
if not isinstance(ntfy_topic, str):
ntfy_topic = None
# 2. register --------------------------------------------------------
try:
self._devices.upsert(device_id, device_name, caps, fcm_token, ntfy_topic)
except Exception:
logger.warning("android: device registry upsert failed", exc_info=True)
conn = DeviceConnection(
device_id=device_id,
device_name=device_name,
ws=ws,
caps=caps,
fcm_token=fcm_token,
ntfy_topic=ntfy_topic,
)
async with self._lock:
old = self._connections.pop(device_id, None)
self._connections[device_id] = conn
if old is not None:
# Same device re-paired from a new socket: the new one wins.
try:
await old.ws.close(code=CLOSE_REPLACED, reason="replaced by newer connection")
except Exception:
pass
ack = protocol.hello_ack(
server_caps=self._adapter.server_caps(),
sync_cursor=0, # outbox lands in M3; cursor starts at 0
channels=self._adapter.channel_list(),
)
try:
await ws.send(ack.to_json())
except Exception:
return
logger.info("android: device paired: %s (%s)", device_name, device_id)
# 3. frame loop ------------------------------------------------------
try:
async for raw in ws:
await self._on_frame(ws, device_id, raw)
except ConnectionClosed:
pass
except Exception:
logger.warning("android: frame loop error for %s", device_id, exc_info=True)
finally:
async with self._lock:
current = self._connections.get(device_id)
if current is not None and current.ws is ws:
self._connections.pop(device_id, None)
logger.info("android: device disconnected: %s", device_id)
# ── Inbound dispatch ──────────────────────────────────────────────────
async def _on_frame(self, ws: ServerConnection, device_id: str, raw: Any) -> None:
frame = protocol.Frame.from_json(raw)
if frame is None:
return # malformed / unknown binary: ignore (forward-compat)
if frame.type == protocol.TYPE_PING:
ts = frame.payload.get("ts")
await self._send_quiet(ws, protocol.pong(ts if isinstance(ts, int) else None))
elif frame.type == protocol.TYPE_MESSAGE_SEND:
await self._adapter.on_message_send(frame, device_id)
elif frame.type == "fcm.register":
fcm_token = frame.payload.get("fcm_token")
ntfy_topic = frame.payload.get("ntfy_topic")
if isinstance(fcm_token, str) or isinstance(ntfy_topic, str):
try:
self._devices.update_push_tokens(
device_id,
fcm_token=fcm_token if isinstance(fcm_token, str) else None,
ntfy_topic=ntfy_topic if isinstance(ntfy_topic, str) else None,
)
except Exception:
logger.warning("android: fcm.register update failed", exc_info=True)
# Unknown types are ignored (forward-compat).
# ── Helpers ───────────────────────────────────────────────────────────
async def _send_quiet(self, ws: ServerConnection, frame: protocol.Frame) -> None:
try:
await ws.send(frame.to_json())
except Exception:
pass
async def _reject(self, ws: ServerConnection, reason: str) -> None:
await self._send_quiet(ws, protocol.error(protocol.ERR_AUTH, reason))
await self._close_quiet(ws, CLOSE_AUTH_FAILED, "auth failed")
async def _close_quiet(self, ws: ServerConnection, code: int, reason: str) -> None:
try:
await ws.close(code=code, reason=reason)
except Exception:
pass