RED->GREEN->REFACTOR cycle: - UI node has state store (key-value), action bindings (op/var), and local action handlers (inc/dec/set/toggle — no LLM round-trip) - Thinker self-model: knows its environment, that ACTIONS create real buttons, that UI handles state locally. Emits var/op payload for stateful actions. - Thinker's context includes UI state so it can report current values - /api/clear resets UI state, bindings, and controls - Test runner: action_match for fuzzy action names, persistent actions across steps, _stream_text restored - Counter test: 16/16 passed (create, read, inc, inc, dec, verify) - Pub test: 20/20 passed (conversation, language switch, tool use, mood) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
162 lines
6.9 KiB
Python
162 lines
6.9 KiB
Python
"""Runtime: wires all nodes together into a processing pipeline."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
from fastapi import WebSocket
|
|
|
|
from .types import Envelope, Command
|
|
from .process import ProcessManager
|
|
from .nodes import SensorNode, InputNode, OutputNode, ThinkerNode, MemorizerNode, UINode
|
|
|
|
log = logging.getLogger("runtime")
|
|
|
|
TRACE_FILE = Path(__file__).parent.parent / "trace.jsonl"
|
|
|
|
|
|
class Runtime:
|
|
def __init__(self, ws: WebSocket, user_claims: dict = None, origin: str = "",
|
|
broadcast: Callable = None):
|
|
self.ws = ws
|
|
self.history: list[dict] = []
|
|
self.MAX_HISTORY = 40
|
|
self._broadcast = broadcast or (lambda e: None)
|
|
|
|
self.input_node = InputNode(send_hud=self._send_hud)
|
|
self.process_manager = ProcessManager(send_hud=self._send_hud)
|
|
self.thinker = ThinkerNode(send_hud=self._send_hud, process_manager=self.process_manager)
|
|
self.output_node = OutputNode(send_hud=self._send_hud)
|
|
self.ui_node = UINode(send_hud=self._send_hud)
|
|
self.memorizer = MemorizerNode(send_hud=self._send_hud)
|
|
self.sensor = SensorNode(send_hud=self._send_hud)
|
|
self.sensor.start(get_memo_state=lambda: self.memorizer.state)
|
|
|
|
claims = user_claims or {}
|
|
log.info(f"[runtime] user_claims: {claims}")
|
|
self.identity = claims.get("name") or claims.get("preferred_username") or claims.get("username") or "unknown"
|
|
log.info(f"[runtime] resolved identity: {self.identity}")
|
|
self.channel = origin or "unknown"
|
|
|
|
self.memorizer.state["user_name"] = self.identity
|
|
self.memorizer.state["situation"] = f"authenticated on {self.channel}" if origin else "local session"
|
|
|
|
async def _send_hud(self, data: dict):
|
|
await self.ws.send_text(json.dumps({"type": "hud", **data}))
|
|
trace_entry = {"ts": time.strftime("%Y-%m-%d %H:%M:%S.") + f"{time.time() % 1:.3f}"[2:], **data}
|
|
try:
|
|
with open(TRACE_FILE, "a", encoding="utf-8") as f:
|
|
f.write(json.dumps(trace_entry, ensure_ascii=False) + "\n")
|
|
if TRACE_FILE.exists() and TRACE_FILE.stat().st_size > 500_000:
|
|
lines = TRACE_FILE.read_text(encoding="utf-8").strip().split("\n")
|
|
TRACE_FILE.write_text("\n".join(lines[-500:]) + "\n", encoding="utf-8")
|
|
except Exception as e:
|
|
log.error(f"trace write error: {e}")
|
|
self._broadcast(trace_entry)
|
|
|
|
async def _stream_text(self, text: str):
|
|
"""Stream pre-formed text to the client as deltas."""
|
|
try:
|
|
chunk_size = 12
|
|
for i in range(0, len(text), chunk_size):
|
|
await self.ws.send_text(json.dumps({"type": "delta", "content": text[i:i + chunk_size]}))
|
|
await self.ws.send_text(json.dumps({"type": "done"}))
|
|
except Exception:
|
|
pass # WS may not be connected (e.g. API-only calls)
|
|
|
|
async def _run_output_and_ui(self, thought, mem_ctx):
|
|
"""Run Output and UI nodes in parallel. Returns (response_text, controls)."""
|
|
output_task = asyncio.create_task(
|
|
self.output_node.process(thought, self.history, self.ws, memory_context=mem_ctx))
|
|
ui_task = asyncio.create_task(
|
|
self.ui_node.process(thought, self.history, memory_context=mem_ctx))
|
|
|
|
# Output streams to WS, UI returns controls — both run concurrently
|
|
response, controls = await asyncio.gather(output_task, ui_task)
|
|
|
|
# Send controls after Output starts streaming (UI may finish first or after)
|
|
if controls:
|
|
await self.ws.send_text(json.dumps({"type": "controls", "controls": controls}))
|
|
|
|
return response
|
|
|
|
async def handle_action(self, action: str, data: dict = None):
|
|
"""Handle a structured UI action (button click etc.)."""
|
|
self.sensor.note_user_activity()
|
|
|
|
# Try local UI action first (inc, dec, toggle — no LLM needed)
|
|
result, controls = await self.ui_node.process_local_action(action, data)
|
|
if result is not None:
|
|
# Local action handled — send controls update + short response
|
|
if controls:
|
|
await self.ws.send_text(json.dumps({"type": "controls", "controls": controls}))
|
|
await self._stream_text(result)
|
|
self.history.append({"role": "user", "content": f"[clicked {action}]"})
|
|
self.history.append({"role": "assistant", "content": result})
|
|
return
|
|
|
|
# Complex action — needs Thinker reasoning
|
|
action_desc = f"ACTION: {action}"
|
|
if data:
|
|
action_desc += f" | data: {json.dumps(data)}"
|
|
self.history.append({"role": "user", "content": action_desc})
|
|
|
|
sensor_lines = self.sensor.get_context_lines()
|
|
mem_ctx = self.memorizer.get_context_block(sensor_lines=sensor_lines, ui_state=self.ui_node.state)
|
|
|
|
command = Command(instruction=f"User clicked UI button: {action}", source_text=action_desc)
|
|
thought = await self.thinker.process(command, self.history, memory_context=mem_ctx)
|
|
|
|
response = await self._run_output_and_ui(thought, mem_ctx)
|
|
self.history.append({"role": "assistant", "content": response})
|
|
|
|
await self.memorizer.update(self.history)
|
|
|
|
if len(self.history) > self.MAX_HISTORY:
|
|
self.history = self.history[-self.MAX_HISTORY:]
|
|
|
|
async def handle_message(self, text: str):
|
|
# Detect ACTION: prefix from API/test runner
|
|
if text.startswith("ACTION:"):
|
|
parts = text.split("|", 1)
|
|
action = parts[0].replace("ACTION:", "").strip()
|
|
data = None
|
|
if len(parts) > 1:
|
|
try:
|
|
data = json.loads(parts[1].replace("data:", "").strip())
|
|
except (json.JSONDecodeError, Exception):
|
|
pass
|
|
return await self.handle_action(action, data)
|
|
|
|
envelope = Envelope(
|
|
text=text,
|
|
user_id="nico",
|
|
session_id="test",
|
|
timestamp=time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
)
|
|
|
|
self.sensor.note_user_activity()
|
|
self.history.append({"role": "user", "content": text})
|
|
|
|
sensor_lines = self.sensor.get_context_lines()
|
|
mem_ctx = self.memorizer.get_context_block(sensor_lines=sensor_lines, ui_state=self.ui_node.state)
|
|
|
|
command = await self.input_node.process(
|
|
envelope, self.history, memory_context=mem_ctx,
|
|
identity=self.identity, channel=self.channel)
|
|
|
|
thought = await self.thinker.process(command, self.history, memory_context=mem_ctx)
|
|
|
|
# Output (voice) and UI (screen) run in parallel
|
|
response = await self._run_output_and_ui(thought, mem_ctx)
|
|
|
|
self.history.append({"role": "assistant", "content": response})
|
|
|
|
await self.memorizer.update(self.history)
|
|
|
|
if len(self.history) > self.MAX_HISTORY:
|
|
self.history = self.history[-self.MAX_HISTORY:]
|