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>
107 lines
3.6 KiB
Python
107 lines
3.6 KiB
Python
"""Graph Engine: loads graph definitions, instantiates nodes, executes pipelines."""
|
|
|
|
import importlib
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from .nodes import NODE_REGISTRY
|
|
from .process import ProcessManager
|
|
|
|
log = logging.getLogger("runtime")
|
|
|
|
GRAPHS_DIR = Path(__file__).parent / "graphs"
|
|
|
|
|
|
def list_graphs() -> list[dict]:
|
|
"""List all available graph definitions."""
|
|
graphs = []
|
|
for f in sorted(GRAPHS_DIR.glob("*.py")):
|
|
if f.name.startswith("_"):
|
|
continue
|
|
mod = _load_graph_module(f.stem)
|
|
if mod:
|
|
graphs.append({
|
|
"name": getattr(mod, "NAME", f.stem),
|
|
"description": getattr(mod, "DESCRIPTION", ""),
|
|
"file": f.name,
|
|
})
|
|
return graphs
|
|
|
|
|
|
def load_graph(name: str) -> dict:
|
|
"""Load a graph definition by name. Returns the module's attributes as a dict."""
|
|
# Try matching by NAME attribute first, then by filename
|
|
for f in GRAPHS_DIR.glob("*.py"):
|
|
if f.name.startswith("_"):
|
|
continue
|
|
mod = _load_graph_module(f.stem)
|
|
if mod and getattr(mod, "NAME", "") == name:
|
|
return _graph_from_module(mod)
|
|
# Fallback: match by filename stem
|
|
mod = _load_graph_module(name)
|
|
if mod:
|
|
return _graph_from_module(mod)
|
|
raise ValueError(f"Graph '{name}' not found")
|
|
|
|
|
|
def _load_graph_module(stem: str):
|
|
"""Import a graph module by stem name."""
|
|
try:
|
|
return importlib.import_module(f".graphs.{stem}", package="agent")
|
|
except (ImportError, ModuleNotFoundError) as e:
|
|
log.error(f"[engine] failed to load graph '{stem}': {e}")
|
|
return None
|
|
|
|
|
|
def _graph_from_module(mod) -> dict:
|
|
"""Extract graph definition from a module."""
|
|
return {
|
|
"name": getattr(mod, "NAME", "unknown"),
|
|
"description": getattr(mod, "DESCRIPTION", ""),
|
|
"nodes": getattr(mod, "NODES", {}),
|
|
"edges": getattr(mod, "EDGES", []),
|
|
"conditions": getattr(mod, "CONDITIONS", {}),
|
|
"audit": getattr(mod, "AUDIT", {}),
|
|
}
|
|
|
|
|
|
def instantiate_nodes(graph: dict, send_hud, process_manager: ProcessManager = None) -> dict:
|
|
"""Create node instances from a graph definition. Returns {role: node_instance}."""
|
|
nodes = {}
|
|
for role, impl_name in graph["nodes"].items():
|
|
cls = NODE_REGISTRY.get(impl_name)
|
|
if not cls:
|
|
log.error(f"[engine] node class not found: {impl_name}")
|
|
continue
|
|
# ThinkerNode needs process_manager
|
|
if impl_name.startswith("thinker"):
|
|
nodes[role] = cls(send_hud=send_hud, process_manager=process_manager)
|
|
else:
|
|
nodes[role] = cls(send_hud=send_hud)
|
|
log.info(f"[engine] {role} = {impl_name} ({cls.__name__})")
|
|
return nodes
|
|
|
|
|
|
def get_graph_for_cytoscape(graph: dict) -> dict:
|
|
"""Convert graph definition to Cytoscape-compatible elements for frontend."""
|
|
elements = {"nodes": [], "edges": []}
|
|
for role in graph["nodes"]:
|
|
elements["nodes"].append({"data": {"id": role, "label": role}})
|
|
for edge in graph["edges"]:
|
|
src = edge["from"]
|
|
targets = edge["to"] if isinstance(edge["to"], list) else [edge["to"]]
|
|
edge_type = edge.get("type", "data")
|
|
for tgt in targets:
|
|
elements["edges"].append({
|
|
"data": {
|
|
"id": f"e-{src}-{tgt}",
|
|
"source": src,
|
|
"target": tgt,
|
|
"edge_type": edge_type,
|
|
"condition": edge.get("condition", ""),
|
|
"carries": edge.get("carries", ""),
|
|
"method": edge.get("method", ""),
|
|
},
|
|
})
|
|
return elements
|