agent-runtime/agent/engine.py
Nico a2bc6347fc v0.13.0: Graph engine, versioned nodes, S3* audit, DB tools, Cytoscape
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>
2026-03-29 00:18:45 +01:00

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