Implement NodePool + contextvar HUD for Phase 2 shared nodes

- agent/node_pool.py: NodePool class creates shared stateless node instances,
  excludes stateful roles (sensor, memorizer, ui)
- agent/nodes/base.py: _current_hud contextvar for per-task HUD isolation,
  Node.hud() checks contextvar first, falls back to instance callback
- 15/15 engine tests green (4 new Phase 2 tests pass)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nico 2026-04-03 18:36:46 +02:00
parent d3350fd502
commit ae2338e70a
2 changed files with 58 additions and 2 deletions

50
agent/node_pool.py Normal file
View File

@ -0,0 +1,50 @@
"""NodePool: shared stateless node instances across all sessions.
Stateless nodes (InputNode, PANode, ExpertNode, etc.) hold no per-session
state only config (model, system prompt). They can safely serve multiple
concurrent sessions. Session-specific HUD routing uses contextvars.
Stateful nodes (SensorNode, MemorizerNode, UINode) hold conversational
state and must be created per-session.
"""
import logging
from .engine import load_graph, instantiate_nodes
log = logging.getLogger("runtime")
# Roles that hold per-session state — always created fresh per Runtime
STATEFUL_ROLES = frozenset({"sensor", "memorizer", "ui"})
async def _noop_hud(data: dict):
"""Placeholder HUD — shared nodes use contextvars for session routing."""
pass
class NodePool:
"""Shared node instances for stateless LLM nodes.
Usage:
pool = NodePool("v4-eras")
# Shared nodes (one instance, all sessions):
input_node = pool.shared["input"]
# Stateful nodes must be created per-session (not in pool)
"""
def __init__(self, graph_name: str = "v4-eras"):
self.graph = load_graph(graph_name)
self.graph_name = graph_name
# Instantiate all nodes with noop HUD (shared nodes use contextvars)
all_nodes = instantiate_nodes(self.graph, send_hud=_noop_hud)
# Split: shared (stateless) vs excluded (stateful)
self.shared = {
role: node for role, node in all_nodes.items()
if role not in STATEFUL_ROLES
}
log.info(f"[pool] created for graph '{graph_name}': "
f"{len(self.shared)} shared, {len(STATEFUL_ROLES)} stateful")

View File

@ -1,11 +1,16 @@
"""Base Node class with context management."""
import contextvars
import logging
from ..llm import estimate_tokens, fit_context
log = logging.getLogger("runtime")
# Per-task HUD callback — set by FrameEngine/Runtime before calling shared nodes.
# Isolates HUD events between concurrent sessions (asyncio.Task-scoped).
_current_hud = contextvars.ContextVar('send_hud', default=None)
class Node:
name: str = "node"
@ -18,10 +23,11 @@ class Node:
self.context_fill_pct = 0
async def hud(self, event: str, **data):
# Always include model on context events so frontend knows what model each node uses
# Use task-scoped HUD if set (shared node pool), else instance callback
hud_fn = _current_hud.get() or self.send_hud
if event == "context" and self.model:
data["model"] = self.model
await self.send_hud({"node": self.name, "event": event, **data})
await hud_fn({"node": self.name, "event": event, **data})
def trim_context(self, messages: list[dict]) -> list[dict]:
"""Fit messages within this node's token budget."""