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:
1 parent
59acf66c89
commit
218c50d688
21 files changed
+3437
-129
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user