Architecture: - Graph engine (engine.py) loads graph definitions, instantiates nodes - Versioned nodes: input_v1, thinker_v1, output_v1, memorizer_v1, director_v1 - NODE_REGISTRY for dynamic node lookup by name - Graph API: /api/graph/active, /api/graph/list, /api/graph/switch - Graph definition: graphs/v1_current.py (7 nodes, 13 edges, 3 edge types) S3* Audit system: - Workspace mismatch detection (server vs browser controls) - Code-without-tools retry (Thinker wrote code but no tool calls) - Intent-without-action retry (request intent but Thinker only produced text) - Dashboard feedback: browser sends workspace state on every message - Sensor continuous comparison on 5s tick State machines: - create_machine / add_state / reset_machine / destroy_machine via function calling - Local transitions (go:) resolve without LLM round-trip - Button persistence across turns Database tools: - query_db tool via pymysql to MariaDB K3s pod (eras2_production) - Table rendering in workspace (tab-separated parsing) - Director pre-planning with Opus for complex data requests - Error retry with corrected SQL Frontend: - Cytoscape.js pipeline graph with real-time node animations - Overlay scrollbars (CSS-only, no reflow) - Tool call/result trace events - S3* audit events in trace Testing: - 167 integration tests (11 test suites) - 22 node-level unit tests (test_nodes/) - Three test levels: node unit, graph integration, scenario Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
201 lines
8.0 KiB
Python
201 lines
8.0 KiB
Python
"""Sensor Node: ticks independently, produces context for other nodes."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from .base import Node
|
|
|
|
log = logging.getLogger("runtime")
|
|
|
|
BERLIN = ZoneInfo("Europe/Berlin")
|
|
|
|
|
|
class SensorNode(Node):
|
|
name = "sensor"
|
|
|
|
def __init__(self, send_hud):
|
|
super().__init__(send_hud)
|
|
self.tick_count = 0
|
|
self.running = False
|
|
self._task: asyncio.Task | None = None
|
|
self.interval = 5
|
|
self.readings: dict[str, dict] = {}
|
|
self._last_user_activity: float = time.time()
|
|
self._prev_memo_state: dict = {}
|
|
self._was_idle = False # True when user crossed idle threshold
|
|
self._idle_threshold = 30 # seconds before considered "away"
|
|
self._browser_dashboard: list = [] # last reported by browser
|
|
self._flags: list[dict] = [] # pending flags for Director
|
|
|
|
def _now(self) -> datetime:
|
|
return datetime.now(BERLIN)
|
|
|
|
def _read_clock(self) -> dict:
|
|
now = self._now()
|
|
current = now.strftime("%H:%M")
|
|
prev = self.readings.get("clock", {}).get("value")
|
|
if current != prev:
|
|
return {"value": current, "detail": now.strftime("%Y-%m-%d %H:%M:%S %A"), "changed_at": time.time()}
|
|
return {}
|
|
|
|
def _read_idle(self) -> dict:
|
|
idle_s = time.time() - self._last_user_activity
|
|
thresholds = [30, 60, 300, 600, 1800]
|
|
prev_idle = self.readings.get("idle", {}).get("_raw", 0)
|
|
for t in thresholds:
|
|
if prev_idle < t <= idle_s:
|
|
if idle_s < 60:
|
|
label = f"{int(idle_s)}s"
|
|
else:
|
|
label = f"{int(idle_s // 60)}m{int(idle_s % 60)}s"
|
|
return {"value": label, "_raw": idle_s, "changed_at": time.time()}
|
|
if "idle" in self.readings:
|
|
self.readings["idle"]["_raw"] = idle_s
|
|
return {}
|
|
|
|
def _read_memo_changes(self, memo_state: dict) -> dict:
|
|
changes = []
|
|
for k, v in memo_state.items():
|
|
prev = self._prev_memo_state.get(k)
|
|
if v != prev and prev is not None:
|
|
changes.append(f"{k}: {prev} -> {v}")
|
|
self._prev_memo_state = dict(memo_state)
|
|
if changes:
|
|
return {"value": "; ".join(changes), "changed_at": time.time()}
|
|
return {}
|
|
|
|
def update_browser_dashboard(self, dashboard: list):
|
|
"""Called when browser reports its current workspace state."""
|
|
self._browser_dashboard = dashboard or []
|
|
|
|
def _read_workspace_mismatch(self, server_controls: list) -> dict:
|
|
"""Compare server-side controls vs browser-reported controls."""
|
|
if not server_controls and not self._browser_dashboard:
|
|
return {}
|
|
server_btns = sorted(c.get("label", "") for c in server_controls if c.get("type") == "button")
|
|
browser_btns = sorted(c.get("label", "") for c in self._browser_dashboard if c.get("type") == "button")
|
|
if server_btns and server_btns != browser_btns:
|
|
detail = f"server={server_btns} browser={browser_btns}"
|
|
return {"value": "mismatch", "detail": detail, "changed_at": time.time()}
|
|
if server_btns and server_btns == browser_btns:
|
|
# Clear previous mismatch
|
|
if self.readings.get("workspace", {}).get("value") == "mismatch":
|
|
return {"value": "synced", "changed_at": time.time()}
|
|
return {}
|
|
|
|
def _check_idle_return(self) -> dict | None:
|
|
"""Detect when user returns after being idle. Returns flag or None."""
|
|
idle_s = time.time() - self._last_user_activity
|
|
if idle_s >= self._idle_threshold and not self._was_idle:
|
|
self._was_idle = True
|
|
return None # return detection happens in note_user_activity
|
|
|
|
def note_user_activity(self):
|
|
idle_s = time.time() - self._last_user_activity
|
|
returned_after = idle_s if self._was_idle else 0
|
|
self._last_user_activity = time.time()
|
|
self.readings["idle"] = {"value": "active", "_raw": 0, "changed_at": time.time()}
|
|
|
|
if returned_after > 0:
|
|
self._was_idle = False
|
|
if returned_after >= self._idle_threshold:
|
|
if returned_after < 60:
|
|
label = f"{int(returned_after)}s"
|
|
else:
|
|
label = f"{int(returned_after // 60)}m{int(returned_after % 60)}s"
|
|
flag = {"type": "idle_return", "away_duration": label,
|
|
"away_seconds": returned_after, "changed_at": time.time()}
|
|
self._flags.append(flag)
|
|
self.readings["idle_return"] = {"value": label, "changed_at": time.time()}
|
|
log.info(f"[sensor] user returned after {label} idle")
|
|
|
|
async def tick(self, memo_state: dict, server_controls: list = None):
|
|
self.tick_count += 1
|
|
deltas = {}
|
|
|
|
for name, reader in [("clock", self._read_clock),
|
|
("idle", self._read_idle)]:
|
|
update = reader()
|
|
if update:
|
|
self.readings[name] = {**self.readings.get(name, {}), **update}
|
|
deltas[name] = update.get("value") or update.get("detail")
|
|
|
|
memo_update = self._read_memo_changes(memo_state)
|
|
if memo_update:
|
|
self.readings["memo_delta"] = memo_update
|
|
deltas["memo_delta"] = memo_update["value"]
|
|
|
|
# Workspace mismatch detection (S3* continuous audit)
|
|
if server_controls is not None:
|
|
ws_update = self._read_workspace_mismatch(server_controls)
|
|
if ws_update:
|
|
self.readings["workspace"] = ws_update
|
|
deltas["workspace"] = ws_update.get("detail") or ws_update.get("value")
|
|
if ws_update.get("value") == "mismatch":
|
|
self._flags.append({"type": "workspace_mismatch",
|
|
"detail": ws_update.get("detail", ""),
|
|
"changed_at": time.time()})
|
|
|
|
# Track idle threshold crossing
|
|
self._check_idle_return()
|
|
|
|
if deltas:
|
|
await self.hud("tick", tick=self.tick_count, deltas=deltas)
|
|
|
|
def consume_flags(self) -> list[dict]:
|
|
"""Return and clear pending flags for Director."""
|
|
flags = self._flags[:]
|
|
self._flags.clear()
|
|
return flags
|
|
|
|
async def _loop(self, get_memo_state, get_server_controls):
|
|
self.running = True
|
|
await self.hud("started", interval=self.interval)
|
|
try:
|
|
while self.running:
|
|
await asyncio.sleep(self.interval)
|
|
try:
|
|
await self.tick(get_memo_state(), server_controls=get_server_controls())
|
|
except Exception as e:
|
|
log.error(f"[sensor] tick error: {e}")
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
self.running = False
|
|
await self.hud("stopped")
|
|
|
|
def start(self, get_memo_state, get_server_controls=None):
|
|
if self._task and not self._task.done():
|
|
return
|
|
if get_server_controls is None:
|
|
get_server_controls = lambda: []
|
|
self._task = asyncio.create_task(self._loop(get_memo_state, get_server_controls))
|
|
|
|
def stop(self):
|
|
self.running = False
|
|
if self._task:
|
|
self._task.cancel()
|
|
|
|
def get_context_lines(self) -> list[str]:
|
|
if not self.readings:
|
|
return ["Sensors: (no sensor node running)"]
|
|
lines = [f"Sensors (tick #{self.tick_count}, {self.interval}s interval):"]
|
|
for name, r in self.readings.items():
|
|
if name.startswith("_"):
|
|
continue
|
|
val = r.get("value", "?")
|
|
detail = r.get("detail")
|
|
age = time.time() - r.get("changed_at", time.time())
|
|
if age < 10:
|
|
age_str = "just now"
|
|
elif age < 60:
|
|
age_str = f"{int(age)}s ago"
|
|
else:
|
|
age_str = f"{int(age // 60)}m ago"
|
|
line = f"- {name}: {detail or val} [{age_str}]"
|
|
lines.append(line)
|
|
return lines
|