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
+3437 -129

No files matched your search

+345 -4
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
``docs/04-wire-protocol.md``).
Milestone M1: hello/hello.ack, message, error, ping/pong.
Milestone M2: message.start/update/stop, reasoning, tool.*, commentary, typing.
Milestone M1: hello/hello.ack, message, message.send, error, ping/pong,
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 M4: media.*.
Milestone M5: notification, fcm.register, read.receipt.
"""
import json
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
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_ACK = "hello.ack"
TYPE_ERROR = "error"
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)